From 594f270c69da5cb14cec50bd5ce6b875f1f5a49a Mon Sep 17 00:00:00 2001 From: Adel Noureddine Date: Wed, 16 Sep 2026 15:52:20 +0200 Subject: [PATCH 1/7] Update to work with the updated format of PowerJoular. Few other fixes to ring buffer and CSV. Drop HTTP endpoint. Remove external dependencies for HTTP and JNA so the agent is very light now --- README.md | 71 ++-- pom.xml | 62 +--- .../agent/MonitoringHandler.java | 8 +- .../agent/source/JoularCoreCSVSource.java | 166 --------- .../agent/source/JoularCoreHttpSource.java | 176 ---------- .../source/JoularCoreRingBufferSource.java | 170 ---------- .../agent/source/LinuxRaplPowerSource.java | 5 + .../agent/source/PowerJoularCsvSource.java | 239 +++++++++++++ .../source/PowerJoularRingBufferSource.java | 197 +++++++++++ .../agent/source/PowerSource.java | 16 +- .../agent/source/PowerSourceFactory.java | 5 +- .../agent/utils/AgentProperties.java | 22 +- src/main/resources/joularcodejava.properties | 26 +- .../agent/source/JoularCoreCSVSourceTest.java | 320 ----------------- .../source/JoularCoreHttpSourceTest.java | 321 ------------------ .../JoularCoreRingBufferSourceTest.java | 262 -------------- .../source/PowerJoularCsvSourceTest.java | 300 ++++++++++++++++ .../PowerJoularRingBufferSourceTest.java | 263 ++++++++++++++ .../agent/source/PowerSourceFactoryTest.java | 57 +--- .../agent/source/TestLogging.java | 38 +++ .../agent/utils/AgentPropertiesTest.java | 39 +-- 21 files changed, 1144 insertions(+), 1619 deletions(-) delete mode 100644 src/main/java/org/noureddine/joular/joularcodejava/agent/source/JoularCoreCSVSource.java delete mode 100644 src/main/java/org/noureddine/joular/joularcodejava/agent/source/JoularCoreHttpSource.java delete mode 100644 src/main/java/org/noureddine/joular/joularcodejava/agent/source/JoularCoreRingBufferSource.java create mode 100644 src/main/java/org/noureddine/joular/joularcodejava/agent/source/PowerJoularCsvSource.java create mode 100644 src/main/java/org/noureddine/joular/joularcodejava/agent/source/PowerJoularRingBufferSource.java delete mode 100644 src/test/java/org/noureddine/joular/joularcodejava/agent/source/JoularCoreCSVSourceTest.java delete mode 100644 src/test/java/org/noureddine/joular/joularcodejava/agent/source/JoularCoreHttpSourceTest.java delete mode 100644 src/test/java/org/noureddine/joular/joularcodejava/agent/source/JoularCoreRingBufferSourceTest.java create mode 100644 src/test/java/org/noureddine/joular/joularcodejava/agent/source/PowerJoularCsvSourceTest.java create mode 100644 src/test/java/org/noureddine/joular/joularcodejava/agent/source/PowerJoularRingBufferSourceTest.java create mode 100644 src/test/java/org/noureddine/joular/joularcodejava/agent/source/TestLogging.java diff --git a/README.md b/README.md index ab08827..3245c0f 100644 --- a/README.md +++ b/README.md @@ -12,11 +12,11 @@ This project is part of [Joular Code](https://github.com/joular/joularcode), and - Monitor power consumption and energy of each method and execution branch at runtime - Works as a Java agent — no source code instrumentation or modification needed - Samples the JVM stack at high frequency (default: every 10 ms) and attributes energy every second -- Supports four power data source backends: - - Shared memory ring buffer (IPC) — lowest latency, recommended - - CSV file — file-based polling - - HTTP endpoint — remote or containerized setups - - Linux RAPL powercap PKG domain — direct hardware counter source +- No runtime dependencies: the agent runs on the JDK alone +- Supports three power data source backends: + - Shared memory ring buffer (IPC): lowest latency, recommended + - CSV file: file-based polling + - Linux RAPL powercap PKG domain: direct hardware counter source - Generates CSV files with per-method and per-branch power (Watts) and energy (Joules) - Produces two output sets: one for all methods (including JDK internals), one filtered to your application packages - Configurable method filtering by package/class prefix to focus energy data on your code @@ -26,12 +26,12 @@ This project is part of [Joular Code](https://github.com/joular/joularcode), and Joular Code - Java runs as a Java instrumentation agent alongside your application. Every monitoring cycle (default: 1 second), it: -1. **Samples the JVM stack** — every `stack-monitoring-sample-rate` milliseconds, it captures the stack trace of every `RUNNABLE` thread, building sample counts for each call branch. -2. **Measures thread CPU time** — it takes CPU-time snapshots at the start and end of each cycle using `ThreadMXBean`, computing how much CPU time each thread consumed during the window. -3. **Reads system power** from Joular Core — the total CPU power in Watts for the current monitoring cycle. -4. **Scales to process power** — it estimates the JVM process's share of CPU power using `(processCpuLoad / systemCpuLoad) * totalCpuPower`. -5. **Attributes energy to methods** — each thread receives a fraction of process power proportional to its CPU time. Within each thread, power is further distributed to call branches proportionally to their sample counts. -6. **Writes results** to CSV files — both power (W) and energy (J = W × interval) are recorded per branch per cycle. +1. **Samples the JVM stack**: every `stack-monitoring-sample-rate` milliseconds, it captures the stack trace of every `RUNNABLE` thread, building sample counts for each call branch. +2. **Measures thread CPU time**: it takes CPU-time snapshots at the start and end of each cycle using `ThreadMXBean`, computing how much CPU time each thread consumed during the window. +3. **Reads system power** from PowerJoular: the total CPU power in Watts for the current monitoring cycle. +4. **Scales to process power**: it estimates the JVM process's share of CPU power using `(processCpuLoad / systemCpuLoad) * totalCpuPower`. +5. **Attributes energy to methods**: each thread receives a fraction of process power proportional to its CPU time. Within each thread, power is further distributed to call branches proportionally to their sample counts. +6. **Writes results** to CSV files: both power (W) and energy (J = W × interval) are recorded per branch per cycle. ## :package: Compilation and Installation @@ -39,7 +39,7 @@ Joular Code - Java runs as a Java instrumentation agent alongside your applicati - Java 21 or later - Apache Maven 3.6 or later -- [Joular Core](https://github.com/joular/joularcore) for `ringbuffer`, `csv`, or `http` power sources, or Linux powercap/RAPL access for `rapl` +- [PowerJoular](https://github.com/joular/powerjoular) 2.0.0 or later for the `ringbuffer` and `csv` power sources, or Linux powercap/RAPL access for `rapl` ### Build @@ -57,11 +57,13 @@ This produces a fat JAR at: target/joularcodejava-.jar ``` -The JAR bundles all dependencies (including JNA) via the Maven Shade plugin, so no additional classpath setup is needed. +The agent has no runtime dependencies, so the JAR holds nothing but its own classes and needs no additional classpath setup. ## :bulb: Usage -Joular Code - Java attaches to your Java application as a Java agent. Start [Joular Core](https://github.com/joular/joularcore) first when using `ringbuffer`, `csv`, or `http`; on Linux, `rapl` can read CPU package power directly from the powercap interface. +Joular Code - Java attaches to your Java application as a Java agent. Start [PowerJoular](https://github.com/joular/powerjoular) first when using `ringbuffer` or `csv`; on Linux, `rapl` can read CPU package power directly from the powercap interface. + +PowerJoular measures the hardware through the [Joular Core](https://github.com/joular/joularcore) library. ### Basic usage @@ -95,10 +97,9 @@ All configuration is done via a `joularcodejava.properties` file. Joular Code - | Property | Default | Description | |---|---|---| -| `power-source-type` | `ringbuffer` | Power data backend: `ringbuffer`, `csv`, `http`, or `rapl` | -| `joular-core-ringbuffer-path` | OS-dependent | Path to the Joular Core ring buffer (see below) | -| `joular-core-csv-path` | `joularcore-data.csv` | Path to the Joular Core CSV output file | -| `joular-core-http-url` | `http://localhost:8080/data` | URL of the Joular Core HTTP endpoint | +| `power-source-type` | `ringbuffer` | Power data backend: `ringbuffer`, `csv`, or `rapl` | +| `powerjoular-ringbuffer-path` | OS-dependent | Path to the PowerJoular ring buffer (see below) | +| `powerjoular-csv-path` | `powerjoular-data.csv` | Path to the PowerJoular system CSV file | | `stack-monitoring-sample-rate` | `10` | Stack sampling interval in milliseconds. Lower = more accurate but higher overhead | | `results-path` | `joular-code-java-results` | Directory where CSV result files are written | | `methods-filtering-prefix` | *(empty)* | Comma-separated list of package/class prefixes to filter app-specific methods (e.g., `com.example,org.myapp`) | @@ -107,34 +108,35 @@ All configuration is done via a `joularcodejava.properties` file. Joular Code - #### Ring buffer (recommended) -The ring buffer backend reads power data from a POSIX shared memory or Windows named shared memory object written by Joular Core. This is the lowest-latency option and has minimal overhead. +The ring buffer backend reads the shared memory area PowerJoular writes with `-r`. This is the lowest-latency option and has minimal overhead. The area is a plain file on every OS, mapped read-only by the agent. Default paths by OS: - **Linux**: `/dev/shm/joularcorering` - **macOS**: `/tmp/joularcorering` -- **Windows**: `Local\JoularCoreRing` +- **Windows**: `%PROGRAMDATA%\joularcorering` ```properties power-source-type=ringbuffer -joular-core-ringbuffer-path=/dev/shm/joularcorering +powerjoular-ringbuffer-path=/dev/shm/joularcorering ``` + #### CSV file -Joular Core can be configured to write power data to a CSV file. Joular Code - Java reads the latest row from that file each monitoring cycle. Use this when IPC is unavailable. The parser expects Joular Core's simple comma-separated output where the first column is a numeric timestamp and the second column is CPU power in watts. +PowerJoular writes power data to a CSV file with `-f`, which adds a row every second, or with `-o`, which keeps only the latest row and carries no header. Joular Code - Java reads the last row each monitoring cycle and supports both. Use this when the ring buffer is unavailable; `-o` is the better of the two here, since the file stays one row long. -```properties -power-source-type=csv -joular-core-csv-path=/path/to/joularcore-data.csv -``` +The file holding the power of the whole system has five columns: -#### HTTP endpoint +``` +Timestamp,CPU Usage,Total Power,CPU Power,GPU Power +1756681930,0.2460,18.4500,15.2000,3.2500 +``` -Reads power data from a trusted JSON HTTP endpoint exposed by Joular Core. The response must be a JSON object containing a top-level numeric `cpu_power` field. Suitable for remote or containerized deployments when the endpoint is controlled by you or your infrastructure. +The agent reads **CPU Power**, the fourth column. It recognises the file by how many columns its rows carry, so it reads both the form with a header and the headerless one `-o` writes. ```properties -power-source-type=http -joular-core-http-url=http://localhost:8080/data +power-source-type=csv +powerjoular-csv-path=/path/to/powerjoular-data.csv ``` #### Linux RAPL powercap @@ -193,16 +195,17 @@ timestamp,branch,power_watts,energy_joules,interval_seconds ## :warning: Troubleshooting -- **"Joular Core ring buffer appears stale"** (`WARNING` log): Joular Core has stopped advancing the ring buffer, so confirm that the Joular Core process is still running and still writing data. Until that resumes, Joular Code - Java gives a power value of `0.0`. -- **"Could not read power data from CSV: ..."** (`WARNING` log): the configured `joular-core-csv-path` does not exist. Check that Joular Core is running in CSV export mode and writing to the same path. The warning is logged only once until the file becomes available again. -- **HTTP mode returns 0.0 power**: the HTTP endpoint must return a JSON object containing a `"cpu_power": ` key directly on the top-level object (not nested). Keys like `"last_cpu_power"` or `"cpu_power_limit"` are ignored. +- **"No fresh power data ... for 5 cycles"** (`WARNING` log): PowerJoular has stopped writing, or is writing somewhere else. Confirm it is still running, with `-r` for the ring buffer or `-f`/`-o` for CSV. A brief gap is covered by the last value read; past five cycles the agent reports `0.0` and attributes no energy. +- **"Could not open the PowerJoular ring buffer ..."** (`WARNING` log): the ring buffer memory area is not there yet. This is not fatal, the agent retries on every cycle, so starting the JVM before PowerJoular is fine. If it never clears, check `powerjoular-ringbuffer-path`. +- **"Could not read power data from ..."** (`WARNING` log): the configured `powerjoular-csv-path` does not exist. Check that PowerJoular is running with `-f` or `-o` and writing to the same path. The warning is logged only once until the file appears. +- **"... is the file PowerJoular writes for one monitored process"** (`SEVERE` log): the CSV has three columns, so it holds the power of a single process, which the agent would scale a second time. Point `powerjoular-csv-path` at the file for the whole system instead. - **RAPL mode fails at startup**: `power-source-type=rapl` is Linux-only and requires readable `/sys/class/powercap/intel-rapl/intel-rapl:0/name`, `energy_uj`, and `max_energy_range_uj` files for the `package-0` domain. Depending on the system, this may require elevated permissions. - **No rows in `methods-power-app.csv`**: if `methods-filtering-prefix` is set, the configured prefix may not match any fully-qualified method name in your application. If it is unset, all observed methods are eligible for both output files. ## :information_source: Notes - Joular Code - Java requires `com.sun.management.OperatingSystemMXBean` to measure process and system CPU load. This is available in all standard HotSpot JVMs (OpenJDK, Oracle JDK). Minimal or embedded JVMs that do not provide this class are not supported. -- Thread CPU time attribution requires `ThreadMXBean.isThreadCpuTimeSupported()` to return `true`. If it does not, Joular Core - Java will fail. +- Thread CPU time attribution requires `ThreadMXBean.isThreadCpuTimeSupported()` to return `true`. If it does not, Joular Code - Java will fail. - The agent's own monitoring thread is excluded from all energy measurements. - Power values of `0.0` are suppressed in the output (rows with zero power are not written). - The `NO_COLOR` environment variable disables ANSI color output in the agent banner. diff --git a/pom.xml b/pom.xml index a51986d..16916e3 100644 --- a/pom.xml +++ b/pom.xml @@ -54,20 +54,10 @@ - - net.java.dev.jna - jna - 5.18.1 - - - tools.jackson.core - jackson-core - 3.1.3 - org.junit.jupiter junit-jupiter - 6.0.3 + 6.1.3 test @@ -83,7 +73,7 @@ org.apache.maven.plugins maven-enforcer-plugin - 3.6.2 + 3.6.3 enforce-java-version @@ -104,21 +94,21 @@ org.apache.maven.plugins maven-compiler-plugin - 3.15.0 + 3.16.0 org.apache.maven.plugins maven-surefire-plugin - 3.5.5 + 3.6.0 - -Xshare:off -javaagent:${settings.localRepository}/org/mockito/mockito-core/${mockito.version}/mockito-core-${mockito.version}.jar -Dnet.bytebuddy.experimental=true --enable-native-access=ALL-UNNAMED + -Xshare:off -javaagent:${settings.localRepository}/org/mockito/mockito-core/${mockito.version}/mockito-core-${mockito.version}.jar -Dnet.bytebuddy.experimental=true true org.apache.maven.plugins maven-jar-plugin - 3.5.0 + 3.5.1 true @@ -132,46 +122,6 @@ - - org.apache.maven.plugins - maven-shade-plugin - 3.6.2 - - - package - - shade - - - false - false - - - *:* - - META-INF/MANIFEST.MF - META-INF/LICENSE - module-info.class - META-INF/versions/**/module-info.class - - - - - - - org.noureddine.joular.joularcodejava.agent.Agent - org.noureddine.joular.joularcodejava.agent.Agent - true - true - ${project.version} - - - - - - - - diff --git a/src/main/java/org/noureddine/joular/joularcodejava/agent/MonitoringHandler.java b/src/main/java/org/noureddine/joular/joularcodejava/agent/MonitoringHandler.java index af5a9b8..c465c1b 100644 --- a/src/main/java/org/noureddine/joular/joularcodejava/agent/MonitoringHandler.java +++ b/src/main/java/org/noureddine/joular/joularcodejava/agent/MonitoringHandler.java @@ -258,9 +258,15 @@ public void run() { private double estimateProcessShare() { double processCpuLoad = sanitizeCpuLoad(osBean.getProcessCpuLoad()); double systemCpuLoad = sanitizeCpuLoad(osBean.getCpuLoad()); - if (processCpuLoad <= 0 || systemCpuLoad <= 0) { + if (processCpuLoad <= 0) { return 0.0; } + if (systemCpuLoad <= 0) { + // The system CPU load could not be read while the JVM's own CPU load could. + // Falling back to the JVM's load in order to keep the cycle rather than discarding it. + // This means less accurate data, as it amounts to taking the machine as fully loaded, which under-states the share of the power the JVM is given rather than over-stating it. + return processCpuLoad; + } return sanitizeCpuLoad(processCpuLoad / systemCpuLoad); } diff --git a/src/main/java/org/noureddine/joular/joularcodejava/agent/source/JoularCoreCSVSource.java b/src/main/java/org/noureddine/joular/joularcodejava/agent/source/JoularCoreCSVSource.java deleted file mode 100644 index 65499b7..0000000 --- a/src/main/java/org/noureddine/joular/joularcodejava/agent/source/JoularCoreCSVSource.java +++ /dev/null @@ -1,166 +0,0 @@ -/* - * Copyright (c) 2026, Adel Noureddine. - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the - * GNU Lesser General Public License v3.0 (LGPL-3.0-only) - * which accompanies this distribution, and is available at - * https://www.gnu.org/licenses/lgpl-3.0.en.html - * - * Author : Adel Noureddine - */ - -package org.noureddine.joular.joularcodejava.agent.source; - -import java.io.IOException; -import java.io.RandomAccessFile; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.logging.Level; -import java.util.logging.Logger; - -public class JoularCoreCSVSource implements PowerSource { - - private static final Logger logger = Logger.getLogger( - JoularCoreCSVSource.class.getName() - ); - private static final int TAIL_READ_SIZE = 8192; - private final Path csvPath; - private double lastKnownPower = 0.0; - private long lastKnownLength = -1L; - private long lastKnownModified = -1L; - private boolean missingFileWarningLogged = false; - - public JoularCoreCSVSource(String csvPath) { - this.csvPath = Path.of(csvPath); - } - - @Override - public void initialize() throws Exception { - logger.log(Level.INFO, () -> "Initializing CSV source: " + csvPath); - } - - @Override - public double getCurrentPower() { - if (!Files.exists(csvPath) || !Files.isRegularFile(csvPath)) { - if (!missingFileWarningLogged) { - logger.log( - Level.WARNING, - () -> "Could not read power data from CSV: " + csvPath - + ". Ensure Joular Core is running and generating this file." - ); - missingFileWarningLogged = true; - } - return 0; - } - - missingFileWarningLogged = false; - - try { - long currentLength = Files.size(csvPath); - long currentModified = Files.getLastModifiedTime(csvPath).toMillis(); - if (currentLength == lastKnownLength && currentModified == lastKnownModified) { - return lastKnownPower; - } - - String lastLine = readLastNonEmptyLine(); - if (lastLine == null) { - updateFileMetadata(currentLength, currentModified); - return lastKnownPower; - } - - Double parsedPower = parseCpuPower(lastLine); - if (parsedPower != null) { - lastKnownPower = parsedPower; - } - updateFileMetadata(currentLength, currentModified); - } catch (IOException e) { - logger.log(Level.FINE, () -> "Could not read latest power row from CSV: " + csvPath); - } - - return lastKnownPower; - } - - @Override - public void close() {} - - private String readLastNonEmptyLine() throws IOException { - try (RandomAccessFile file = new RandomAccessFile(csvPath.toFile(), "r")) { - long length = file.length(); - if (length <= 0) { - return null; - } - - int bytesToRead = (int) Math.min(TAIL_READ_SIZE, length); - long start = length - bytesToRead; - file.seek(start); - - byte[] data = new byte[bytesToRead]; - file.readFully(data); - - String chunk = new String(data, StandardCharsets.UTF_8); - String[] lines = chunk.split("\\r?\\n"); - // If we did not start reading at the beginning of the file, the first slice in - // `lines` is almost certainly a partial line (we landed mid-record). Skipping - // it avoids parsing a truncated CSV row whose 2nd field may be a wrong-but-valid - // numeric prefix (e.g., "2.3" of "2.345"). - int firstSafeIndex = (start > 0) ? 1 : 0; - for (int i = lines.length - 1; i >= firstSafeIndex; i--) { - String line = lines[i].trim(); - if (line.isEmpty()) { - continue; - } - if (isHeaderLine(line)) { - continue; - } - return line; - } - return null; - } - } - - static Double parseCpuPower(String csvLine) { - String[] parts = csvLine.split(","); - if (parts.length <= 1) { - return null; - } - - String cpuPowerText = parts[1].trim(); - if (cpuPowerText.isEmpty()) { - return null; - } - - try { - double value = Double.parseDouble(cpuPowerText); - if (!Double.isFinite(value) || value < 0) { - return null; - } - return value; - } catch (NumberFormatException e) { - logger.log(Level.FINE, () -> "Could not parse power value: " + cpuPowerText); - return null; - } - } - - static boolean isHeaderLine(String line) { - // A data row's first column is a numeric timestamp; any non-numeric first column - // is treated as a header/comment row. This is structurally robust and avoids - // false positives from data rows that happen to contain known column names. - int comma = line.indexOf(','); - String first = (comma == -1 ? line : line.substring(0, comma)).trim(); - if (first.isEmpty()) { - return true; - } - try { - Double.parseDouble(first); - return false; - } catch (NumberFormatException e) { - return true; - } - } - - private void updateFileMetadata(long length, long modified) { - lastKnownLength = length; - lastKnownModified = modified; - } -} diff --git a/src/main/java/org/noureddine/joular/joularcodejava/agent/source/JoularCoreHttpSource.java b/src/main/java/org/noureddine/joular/joularcodejava/agent/source/JoularCoreHttpSource.java deleted file mode 100644 index 4f4357c..0000000 --- a/src/main/java/org/noureddine/joular/joularcodejava/agent/source/JoularCoreHttpSource.java +++ /dev/null @@ -1,176 +0,0 @@ -/* - * Copyright (c) 2026, Adel Noureddine. - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the - * GNU Lesser General Public License v3.0 (LGPL-3.0-only) - * which accompanies this distribution, and is available at - * https://www.gnu.org/licenses/lgpl-3.0.en.html - * - * Author : Adel Noureddine - */ - -package org.noureddine.joular.joularcodejava.agent.source; - -import java.io.ByteArrayInputStream; -import java.io.InputStream; -import java.net.URI; -import java.net.URISyntaxException; -import java.net.http.HttpClient; -import java.net.http.HttpRequest; -import java.net.http.HttpResponse; -import java.nio.charset.StandardCharsets; -import java.time.Duration; -import java.util.Locale; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.logging.Level; -import java.util.logging.Logger; -import tools.jackson.core.JsonParser; -import tools.jackson.core.JsonToken; -import tools.jackson.core.ObjectReadContext; -import tools.jackson.core.json.JsonFactory; - -public class JoularCoreHttpSource implements PowerSource { - - private static final Logger logger = Logger.getLogger(JoularCoreHttpSource.class.getName()); - private static final int MAX_BODY_BYTES = 64 * 1024; - private static final JsonFactory JSON_FACTORY = JsonFactory.builder().build(); - - private final URI uri; - private final HttpClient httpClient; - private final ExecutorService httpExecutor; - private double lastKnownPower = 0.0; - - public JoularCoreHttpSource(String url) { - this.uri = parseAndValidate(url); - this.httpExecutor = Executors.newSingleThreadExecutor(r -> { - Thread t = new Thread(r, "Joular-Http-Source"); - t.setDaemon(true); - return t; - }); - this.httpClient = HttpClient.newBuilder() - .connectTimeout(Duration.ofSeconds(2)) - .executor(httpExecutor) - .build(); - } - - private static URI parseAndValidate(String url) { - if (url == null || url.trim().isEmpty()) { - throw new IllegalArgumentException("joular-core-http-url is empty"); - } - URI parsed; - try { - parsed = new URI(url.trim()); - } catch (URISyntaxException e) { - throw new IllegalArgumentException("Invalid joular-core-http-url: " + url, e); - } - String scheme = parsed.getScheme(); - if (scheme == null) { - throw new IllegalArgumentException( - "joular-core-http-url must use http or https scheme: " + url); - } - String lower = scheme.toLowerCase(Locale.ROOT); - if (!lower.equals("http") && !lower.equals("https")) { - throw new IllegalArgumentException( - "joular-core-http-url must use http or https scheme, got '" + scheme + "': " + url); - } - if (parsed.getHost() == null || parsed.getHost().isEmpty()) { - throw new IllegalArgumentException("joular-core-http-url is missing host: " + url); - } - return parsed; - } - - @Override - public void initialize() throws Exception { - logger.log(Level.INFO, () -> "Initializing HTTP source: " + uri); - } - - @Override - public double getCurrentPower() { - try { - HttpRequest request = HttpRequest.newBuilder() - .uri(uri) - .timeout(Duration.ofSeconds(1)) - .GET() - .build(); - - HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofInputStream()); - if (response.statusCode() != 200) { - logger.log(Level.FINE, () -> "HTTP data fetch failed with status code: " + response.statusCode()); - return lastKnownPower; - } - - long contentLength = response.headers().firstValueAsLong("Content-Length").orElse(-1L); - if (contentLength > MAX_BODY_BYTES) { - logger.log(Level.WARNING, - () -> "HTTP response Content-Length " + contentLength - + " exceeds " + MAX_BODY_BYTES + " bytes; ignoring."); - return lastKnownPower; - } - - byte[] bytes; - try (InputStream body = response.body()) { - bytes = body.readNBytes(MAX_BODY_BYTES + 1); - } - if (bytes.length > MAX_BODY_BYTES) { - logger.log(Level.WARNING, - () -> "HTTP response exceeded " + MAX_BODY_BYTES + " bytes; ignoring."); - return lastKnownPower; - } - - Double parsed = extractCpuPower(bytes); - if (parsed != null) { - lastKnownPower = parsed; - } - return lastKnownPower; - } catch (Exception e) { - logger.log(Level.FINE, "Error fetching HTTP power data", e); - return lastKnownPower; - } - } - - static Double extractCpuPower(String json) { - if (json == null) { - return null; - } - return extractCpuPower(json.getBytes(StandardCharsets.UTF_8)); - } - - static Double extractCpuPower(byte[] json) { - if (json == null || json.length == 0) { - return null; - } - - try (JsonParser parser = JSON_FACTORY.createParser( - ObjectReadContext.empty(), - new ByteArrayInputStream(json))) { - if (parser.nextToken() != JsonToken.START_OBJECT) { - return null; - } - - while (parser.nextToken() == JsonToken.PROPERTY_NAME) { - String propertyName = parser.currentName(); - JsonToken valueToken = parser.nextToken(); - if ("cpu_power".equals(propertyName)) { - if (valueToken != JsonToken.VALUE_NUMBER_INT && valueToken != JsonToken.VALUE_NUMBER_FLOAT) { - return null; - } - double value = parser.getDoubleValue(); - if (!Double.isFinite(value) || value < 0) { - return null; - } - return value; - } - parser.skipChildren(); - } - } catch (Exception e) { - logger.log(Level.FINE, "Could not parse HTTP power JSON", e); - } - return null; - } - - @Override - public void close() { - httpExecutor.shutdownNow(); - } -} diff --git a/src/main/java/org/noureddine/joular/joularcodejava/agent/source/JoularCoreRingBufferSource.java b/src/main/java/org/noureddine/joular/joularcodejava/agent/source/JoularCoreRingBufferSource.java deleted file mode 100644 index 97a218e..0000000 --- a/src/main/java/org/noureddine/joular/joularcodejava/agent/source/JoularCoreRingBufferSource.java +++ /dev/null @@ -1,170 +0,0 @@ -/* - * Copyright (c) 2026, Adel Noureddine. - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the - * GNU Lesser General Public License v3.0 (LGPL-3.0-only) - * which accompanies this distribution, and is available at - * https://www.gnu.org/licenses/lgpl-3.0.en.html - * - * Author : Adel Noureddine - */ - -package org.noureddine.joular.joularcodejava.agent.source; - -import com.sun.jna.Library; -import com.sun.jna.Native; -import com.sun.jna.Pointer; -import com.sun.jna.PointerType; -import com.sun.jna.WString; -import java.io.RandomAccessFile; -import java.nio.ByteBuffer; -import java.nio.ByteOrder; -import java.nio.channels.FileChannel; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.logging.Level; -import java.util.logging.Logger; - -public class JoularCoreRingBufferSource implements PowerSource { - - private static final Logger logger = Logger.getLogger(JoularCoreRingBufferSource.class.getName()); - private static final boolean IS_WINDOWS = System.getProperty("os.name").toLowerCase().contains("win"); - private static final int ENTRY_SIZE = 48; // u64 timestamp + 5 * f64 (8 bytes each) - private static final int BUFFER_SIZE = 5; - private static final int FILE_SIZE = 8 + BUFFER_SIZE * ENTRY_SIZE; - private static final int STALENESS_THRESHOLD = 10; - - private final String path; - private ByteBuffer buffer; - private HANDLE mappingHandle; - private Pointer mappingPointer; - private double lastKnownPower = 0.0; - private long lastObservedHead = Long.MIN_VALUE; - private int staleCycles = 0; - private boolean staleWarningLogged = false; - - interface Kernel32 extends Library { - Kernel32 INSTANCE = Native.load("kernel32", Kernel32.class); - - int FILE_MAP_READ = 0x0004; - - HANDLE OpenFileMappingW(int desiredAccess, boolean inheritHandle, WString name); - Pointer MapViewOfFile(HANDLE hFileMappingObject, int dwDesiredAccess, int dwFileOffsetHigh, int dwFileOffsetLow, int dwNumberOfBytesToMap); - boolean UnmapViewOfFile(Pointer lpBaseAddress); - boolean CloseHandle(HANDLE hObject); - } - - public static class HANDLE extends PointerType { - public HANDLE() { - super(); - } - } - - public JoularCoreRingBufferSource(String path) { - this.path = path; - } - - @Override - public void initialize() throws Exception { - logger.log(Level.INFO, () -> "Initializing ring buffer source: " + path); - if (IS_WINDOWS) { - if (Files.exists(Path.of(path)) && Files.isRegularFile(Path.of(path))) { - mapFile(path); - } else { - mapWindowsSharedMemory(path); - } - } else { - mapFile(path); - } - } - - private void mapFile(String filePath) throws Exception { - try (RandomAccessFile file = new RandomAccessFile(filePath, "r")) { - this.buffer = file.getChannel().map(FileChannel.MapMode.READ_ONLY, 0, FILE_SIZE); - } - this.buffer.order(ByteOrder.nativeOrder()); - } - - private void mapWindowsSharedMemory(String mappingName) throws Exception { - mappingHandle = Kernel32.INSTANCE.OpenFileMappingW(Kernel32.FILE_MAP_READ, false, new WString(mappingName)); - if (mappingHandle == null || Pointer.nativeValue(mappingHandle.getPointer()) == 0) { - throw new Exception("Could not open file mapping '" + mappingName + "'"); - } - - mappingPointer = Kernel32.INSTANCE.MapViewOfFile(mappingHandle, Kernel32.FILE_MAP_READ, 0, 0, FILE_SIZE); - if (mappingPointer == null || Pointer.nativeValue(mappingPointer) == 0) { - Kernel32.INSTANCE.CloseHandle(mappingHandle); - mappingHandle = null; - throw new Exception("Could not map view of file '" + mappingName + "'"); - } - - this.buffer = mappingPointer.getByteBuffer(0, FILE_SIZE); - this.buffer.order(ByteOrder.nativeOrder()); - } - - @Override - public double getCurrentPower() { - if (buffer == null) return 0; - - // Load-verify pattern: the producer (Joular Core) is a separate process, - // so the data entry at the head slot may still be mid-write when we read it. - // Re-read head after the data load; if head advanced, discard this read. - long head1 = buffer.getLong(0); - if (head1 <= 0) { - trackStaleness(head1); - return lastKnownPower; - } - - int idx = (int) ((head1 - 1) % BUFFER_SIZE); - int offset = 8 + idx * ENTRY_SIZE; - // RingBufferStruct: timestamp (u64), cpu_power, gpu_power, total_power, cpu_usage, pid_app_power - // cpu_power is the 2nd field; skip 8-byte timestamp - double value = buffer.getDouble(offset + 8); - - long head2 = buffer.getLong(0); - if (head1 != head2) { - // Writer advanced mid-read; drop this sample rather than risk a torn value. - trackStaleness(head2); - return lastKnownPower; - } - - trackStaleness(head1); - if (!Double.isFinite(value) || value < 0) { - return lastKnownPower; - } - lastKnownPower = value; - return value; - } - - private void trackStaleness(long currentHead) { - if (currentHead != lastObservedHead) { - lastObservedHead = currentHead; - staleCycles = 0; - staleWarningLogged = false; - return; - } - - staleCycles++; - if (staleCycles == STALENESS_THRESHOLD && !staleWarningLogged) { - logger.log(Level.WARNING, - "Joular Core ring buffer appears stale - head has not advanced for " - + STALENESS_THRESHOLD + " reads. Is Joular Core still running?"); - staleWarningLogged = true; - } - } - - @Override - public void close() { - if (IS_WINDOWS) { - if (mappingPointer != null) { - Kernel32.INSTANCE.UnmapViewOfFile(mappingPointer); - mappingPointer = null; - } - if (mappingHandle != null) { - Kernel32.INSTANCE.CloseHandle(mappingHandle); - mappingHandle = null; - } - } - buffer = null; - } -} diff --git a/src/main/java/org/noureddine/joular/joularcodejava/agent/source/LinuxRaplPowerSource.java b/src/main/java/org/noureddine/joular/joularcodejava/agent/source/LinuxRaplPowerSource.java index 09737fa..b89248d 100644 --- a/src/main/java/org/noureddine/joular/joularcodejava/agent/source/LinuxRaplPowerSource.java +++ b/src/main/java/org/noureddine/joular/joularcodejava/agent/source/LinuxRaplPowerSource.java @@ -22,6 +22,11 @@ import java.util.logging.Level; import java.util.logging.Logger; +/** + * Reads CPU package power from the Linux powercap RAPL interface, without PowerJoular. + * + *

This source reads the hardware itself rather than following an external producer, so the bounded cover described on {@link PowerSource} does not apply to it. + */ public class LinuxRaplPowerSource implements PowerSource { private static final Logger logger = Logger.getLogger(LinuxRaplPowerSource.class.getName()); diff --git a/src/main/java/org/noureddine/joular/joularcodejava/agent/source/PowerJoularCsvSource.java b/src/main/java/org/noureddine/joular/joularcodejava/agent/source/PowerJoularCsvSource.java new file mode 100644 index 0000000..e89399a --- /dev/null +++ b/src/main/java/org/noureddine/joular/joularcodejava/agent/source/PowerJoularCsvSource.java @@ -0,0 +1,239 @@ +/* + * Copyright (c) 2026, Adel Noureddine. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the + * GNU Lesser General Public License v3.0 (LGPL-3.0-only) + * which accompanies this distribution, and is available at + * https://www.gnu.org/licenses/lgpl-3.0.en.html + * + * Author : Adel Noureddine + */ + +package org.noureddine.joular.joularcodejava.agent.source; + +import java.io.IOException; +import java.io.RandomAccessFile; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * Reads CPU power from the system CSV file PowerJoular writes with {@code -f} or {@code -o}. + * + *

The system file holds five columns: + * + *

+ *   Timestamp,CPU Usage,Total Power,CPU Power,GPU Power
+ *   1756681930,0.2460,18.4500,15.2000,3.2500
+ * 
+ * + *

{@code -f} adds a row every second and starts the file with the header. {@code -o} rewrites the file every second with the latest row only and no header, which is the better option here. Both are supported: the file is recognised by how many columns its rows carry, so the header is only ever skipped over and never has to be parsed. + * + *

Monitoring a process makes PowerJoular write a second, three column file holding the power of that process alone. That file is rejected here. + */ +public class PowerJoularCsvSource implements PowerSource { + + private static final Logger logger = Logger.getLogger(PowerJoularCsvSource.class.getName()); + + // How much of the end of the file is read to find the last row + private static final int TAIL_READ_SIZE = 8192; + + // Number of columns in the system file, and in the file of a monitored process + static final int SYSTEM_COLUMNS = 5; + static final int TARGET_COLUMNS = 3; + + // Where CPU Power sits in the system file + static final int SYSTEM_CPU_POWER_INDEX = 3; + + /** + * How old a row may be before it is treated as unreadable. PowerJoular writes once a second, so anything beyond this means it is no longer running. + */ + static final long MAX_ROW_AGE_SECONDS = 10; + + private final Path csvPath; + + private double lastKnownPower = 0.0; + private int staleCycles = 0; + private boolean missingFileWarningLogged = false; + private boolean targetFileErrorLogged = false; + + public PowerJoularCsvSource(String csvPath) { + this.csvPath = Path.of(csvPath); + } + + @Override + public void initialize() { + logger.log(Level.INFO, () -> "Initializing PowerJoular CSV source: " + csvPath); + } + + @Override + public double getCurrentPower() { + Double power = readPower(); + + if (power != null) { + lastKnownPower = power; + staleCycles = 0; + return power; + } + + staleCycles++; + if (staleCycles <= MAX_STALE_CYCLES) { + // In overwrite mode PowerJoular empties the file before writing the new row, so a read can land on nothing at all + return lastKnownPower; + } + + if (staleCycles == MAX_STALE_CYCLES + 1) { + logger.log(Level.WARNING, + () -> "No fresh power data in " + csvPath + " for " + MAX_STALE_CYCLES + + " cycles. Is PowerJoular still running? No energy will be attributed" + + " until it comes back."); + } + // Reporting zero stops the agent attributing energy from a value that is no longer true + return 0.0; + } + + /** + * Reads CPU Power from the last row of the file. + * + * @return the power in watts, or {@code null} when this cycle could not be read. + */ + private Double readPower() { + if (!Files.isRegularFile(csvPath) || !Files.isReadable(csvPath)) { + if (!missingFileWarningLogged) { + logger.log(Level.WARNING, + () -> "Could not read power data from " + csvPath + + ". Ensure PowerJoular is running and writing this file with -f or -o."); + missingFileWarningLogged = true; + } + return null; + } + missingFileWarningLogged = false; + + try { + String lastRow = readLastDataRow(); + if (lastRow == null) { + return null; + } + + String[] columns = lastRow.split(",", -1); + Double timestamp = parseTimestamp(columns[0]); + if (timestamp == null || isStale(timestamp)) { + return null; + } + + return parseCpuPower(columns, cpuPowerIndex(columns.length)); + } catch (IOException e) { + logger.log(Level.FINE, () -> "Could not read the latest row from " + csvPath); + return null; + } + } + + /** + * Check the power index in the CSV file, allowing us to know if we're using the whole system file, or the monitored process file + * + * @return the column index, or -1 when the row cannot be used. + */ + private int cpuPowerIndex(int columns) { + if (columns == SYSTEM_COLUMNS) { + return SYSTEM_CPU_POWER_INDEX; + } + + if (columns == TARGET_COLUMNS) { + if (!targetFileErrorLogged) { + logger.log(Level.SEVERE, + () -> csvPath + " has " + TARGET_COLUMNS + " columns, so it is the file" + + " PowerJoular writes for one monitored process, whose power is already the power of that process alone. Joular Code needs the power of the whole system: point this at the system file instead."); + targetFileErrorLogged = true; + } + return -1; + } + + logger.log(Level.FINE, () -> "Unexpected column count " + columns + " in " + csvPath); + return -1; + } + + /** + * The first column of a row, which PowerJoular writes as a Unix time in seconds + * + * @return the time, or {@code null} when the column holds no number, which is what marks the header row and any comment + */ + static Double parseTimestamp(String firstColumn) { + try { + return Double.parseDouble(firstColumn.trim()); + } catch (NumberFormatException e) { + return null; + } + } + + static Double parseCpuPower(String[] columns, int columnIndex) { + if (columnIndex < 0 || columns.length <= columnIndex) { + return null; + } + + String powerText = columns[columnIndex].trim(); + if (powerText.isEmpty()) { + return null; + } + + try { + double value = Double.parseDouble(powerText); + // PowerJoular writes -1 for a value it could not read at all. + if (!Double.isFinite(value) || value < 0) { + return null; + } + return value; + } catch (NumberFormatException e) { + logger.log(Level.FINE, () -> "Could not parse power value: " + powerText); + return null; + } + } + + /** + * Reads the last row of the file that carries data, skipping the header and any blank line. + */ + private String readLastDataRow() throws IOException { + try (RandomAccessFile file = new RandomAccessFile(csvPath.toFile(), "r")) { + long length = file.length(); + if (length <= 0) { + // In overwrite mode the file is emptied before the new row is written. + return null; + } + + int bytesToRead = (int) Math.min(TAIL_READ_SIZE, length); + long start = length - bytesToRead; + file.seek(start); + + byte[] data = new byte[bytesToRead]; + file.readFully(data); + + String[] lines = new String(data, StandardCharsets.UTF_8).split("\\r?\\n"); + // Reading from part way into the file almost certainly lands mid-row, so the first slice is skipped: a truncated row can still parse, as a wrong but valid number + int firstSafeIndex = (start > 0) ? 1 : 0; + + for (int i = lines.length - 1; i >= firstSafeIndex; i--) { + String line = lines[i].trim(); + // A data row opens with a time; anything else is the header, a comment or a blank. + if (!line.isEmpty() && parseTimestamp(firstColumnOf(line)) != null) { + return line; + } + } + return null; + } + } + + private static String firstColumnOf(String line) { + int comma = line.indexOf(','); + return comma == -1 ? line : line.substring(0, comma); + } + + // Whether the row is older than we are willing to use + private static boolean isStale(double timestamp) { + // A timestamp ahead of us is left alone: the clock may have stepped, and that is not staleness + return System.currentTimeMillis() / 1000L - timestamp > MAX_ROW_AGE_SECONDS; + } + + @Override + public void close() {} +} diff --git a/src/main/java/org/noureddine/joular/joularcodejava/agent/source/PowerJoularRingBufferSource.java b/src/main/java/org/noureddine/joular/joularcodejava/agent/source/PowerJoularRingBufferSource.java new file mode 100644 index 0000000..069de47 --- /dev/null +++ b/src/main/java/org/noureddine/joular/joularcodejava/agent/source/PowerJoularRingBufferSource.java @@ -0,0 +1,197 @@ +/* + * Copyright (c) 2026, Adel Noureddine. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the + * GNU Lesser General Public License v3.0 (LGPL-3.0-only) + * which accompanies this distribution, and is available at + * https://www.gnu.org/licenses/lgpl-3.0.en.html + * + * Author : Adel Noureddine + */ + +package org.noureddine.joular.joularcodejava.agent.source; + +import java.io.IOException; +import java.io.RandomAccessFile; +import java.lang.invoke.VarHandle; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.channels.FileChannel; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * Reads CPU power from the shared memory ring buffer written by PowerJoular's {@code -r} option. + * + *

The area is a plain file on every OS, mapped read-only here. Its layout, in the byte order of the machine, is an 8-byte counter followed by 5 entries of 48 bytes: + * + *

+ *   Offset 0      : u64  head (number of cycles written so far)
+ *   Offset 8      : entry[0]  (48 bytes)
+ *   Offset 56     : entry[1]
+ *   ...
+ *   Each entry:
+ *     +0  u64  timestamp     (Unix time in seconds)
+ *     +8  f64  cpu_power     (watts)   <- the field we read
+ *     +16 f64  gpu_power     (watts)
+ *     +24 f64  total_power   (watts)
+ *     +32 f64  cpu_usage     (0.0 to 1.0)
+ *     +40 f64  pid_app_power (watts, -1 when the monitored process could not be read)
+ *
+ *   Total size: 8 + 5 * 48 = 248 bytes.
+ * 
+ * + *

PowerJoular writes a cycle into the entry the counter points at ({@code head mod 5}) and raises the counter afterwards. This class follows the counter to know when a new cycle has landed, and the entry timestamp to know how old it is. + * + *

The area deliberately outlives PowerJoular on every OS, so a stale file always looks plausible. Freshness is therefore decided by the entry timestamp, not by the counter. + */ +public class PowerJoularRingBufferSource implements PowerSource { + + private static final Logger logger = Logger.getLogger(PowerJoularRingBufferSource.class.getName()); + + // Entry size in bytes: one u64 timestamp plus five f64 values + static final int ENTRY_SIZE = 48; + + // Number of entries in the ring + static final int BUFFER_SIZE = 5; + + // Total area size: the 8-byte head counter followed by the entries + static final int FILE_SIZE = 8 + BUFFER_SIZE * ENTRY_SIZE; + + // Offset of cpu_power within an entry, just past the timestamp + private static final int CPU_POWER_OFFSET = 8; + + /** + * How old an entry may be before it is treated as unreadable. PowerJoular writes once a second, so anything beyond this means it is no longer running. + */ + static final long MAX_ENTRY_AGE_SECONDS = 10; + + private final String path; + + private volatile ByteBuffer buffer; + private double lastKnownPower = 0.0; + private int staleCycles = 0; + private boolean attachWarningLogged = false; + + public PowerJoularRingBufferSource(String path) { + this.path = path; + } + + @Override + public void initialize() { + logger.log(Level.INFO, () -> "Initializing PowerJoular ring buffer source: " + path); + // A missing area is not fatal: PowerJoular may not have started yet, so attaching is retried on every read instead of aborting the agent here. + attach(); + } + + @Override + public double getCurrentPower() { + Double power = readPower(); + + if (power != null) { + lastKnownPower = power; + staleCycles = 0; + return power; + } + + staleCycles++; + if (staleCycles <= MAX_STALE_CYCLES) { + // Cover a momentary hiccup with the last value we trust + return lastKnownPower; + } + + if (staleCycles == MAX_STALE_CYCLES + 1) { + logger.log(Level.WARNING, + () -> "No fresh power data in the PowerJoular ring buffer " + path + " for " + + MAX_STALE_CYCLES + " cycles. Is PowerJoular still running with -r?" + + " No energy will be attributed until it comes back."); + } + // Reporting zero stops the agent attributing energy from a value that is no longer true + return 0.0; + } + + /** + * Reads cpu_power from the newest complete entry. + * + * @return the power in watts, or {@code null} when this cycle could not be read. + */ + private Double readPower() { + ByteBuffer area = this.buffer; + if (area == null) { + area = attach(); + if (area == null) { + return null; + } + } + + // Seqlock read: the producer is a separate process, so the entry the counter points at may still be mid-write. Read the counter, then the entry, then the counter again. + // A counter that moved means the entry may be torn, so the sample is dropped + long head1 = area.getLong(0); + if (head1 <= 0) { + return null; + } + + // The fences pair with the __sync_synchronize() PowerJoular issues before publishing the counter. + // ByteBuffer loads are plain, so without them a processor that reorders its reads, as the ARM ones of Apple Silicon and the Raspberry Pi do, could pair a new counter with a previous entry + VarHandle.acquireFence(); + + int index = (int) ((head1 - 1) % BUFFER_SIZE); + int offset = 8 + index * ENTRY_SIZE; + long timestamp = area.getLong(offset); + double value = area.getDouble(offset + CPU_POWER_OFFSET); + + VarHandle.acquireFence(); + + if (head1 != area.getLong(0)) { + return null; + } + + if (isStale(timestamp)) { + return null; + } + + if (!Double.isFinite(value) || value < 0) { + // PowerJoular writes -1 for a value it could not read at all. + return null; + } + + return value; + } + + /** + * Maps the area, or returns {@code null} when it is not there yet or is too short to hold a complete ring. A short file means PowerJoular is between creating and sizing it + */ + private ByteBuffer attach() { + try (RandomAccessFile file = new RandomAccessFile(path, "r")) { + if (file.length() < FILE_SIZE) { + // Mapping past the end of a file faults on access rather than failing here. + return null; + } + ByteBuffer mapped = file.getChannel().map(FileChannel.MapMode.READ_ONLY, 0, FILE_SIZE); + mapped.order(ByteOrder.nativeOrder()); + this.buffer = mapped; + attachWarningLogged = false; + logger.log(Level.FINE, () -> "Attached to the PowerJoular ring buffer: " + path); + return mapped; + } catch (IOException | RuntimeException e) { + if (!attachWarningLogged) { + logger.log(Level.WARNING, + () -> "Could not open the PowerJoular ring buffer " + path + + ". Ensure PowerJoular is running with -r. Retrying on every cycle."); + attachWarningLogged = true; + } + return null; + } + } + + private static boolean isStale(long timestamp) { + // A timestamp ahead of us is left alone: the clock may have stepped, and that is not staleness + return System.currentTimeMillis() / 1000L - timestamp > MAX_ENTRY_AGE_SECONDS; + } + + @Override + public void close() { + // The mapping is released by the garbage collector, so dropping the reference is all we can do + buffer = null; + } +} diff --git a/src/main/java/org/noureddine/joular/joularcodejava/agent/source/PowerSource.java b/src/main/java/org/noureddine/joular/joularcodejava/agent/source/PowerSource.java index e4c6bc1..4c73fb7 100644 --- a/src/main/java/org/noureddine/joular/joularcodejava/agent/source/PowerSource.java +++ b/src/main/java/org/noureddine/joular/joularcodejava/agent/source/PowerSource.java @@ -18,15 +18,25 @@ *

    *
  • Before any successful read, {@link #getCurrentPower()} returns {@code 0.0}.
  • *
  • On a successful read, the returned value is cached as the "last known power".
  • - *
  • On transient errors (network blip, mid-write torn reads, parse failures, missing - * row, temporarily unreadable device data), implementations return the last known power - * so the agent smooths over momentary producer unavailability.
  • + *
  • On transient errors (mid-write torn reads, parse failures, missing row, temporarily + * unreadable device data), implementations return the last known power so the agent smooths + * over momentary producer unavailability.
  • + *
  • That cover is bounded. After a handful of consecutive unreadable cycles, implementations + * backed by an external producer report {@code 0.0} rather than going on replaying a value + * that is no longer true, and log a warning once. Zero means no energy is attributed for the + * cycle, which is preferable to attributing energy that was never measured.
  • *
  • Implementations must reject {@code NaN}, infinite, and negative values returned by * the underlying source — these are treated as transient errors.
  • *
  • Returned values are CPU power in Watts and must be {@code >= 0} and finite.
  • *
*/ public interface PowerSource { + + /** + * How many consecutive unreadable cycles an external source (csv, ring buffer) covers with the last known value before reporting {@code 0.0}. + */ + int MAX_STALE_CYCLES = 5; + /** * Initialize the power source connection. * diff --git a/src/main/java/org/noureddine/joular/joularcodejava/agent/source/PowerSourceFactory.java b/src/main/java/org/noureddine/joular/joularcodejava/agent/source/PowerSourceFactory.java index 2ca03da..ad16913 100644 --- a/src/main/java/org/noureddine/joular/joularcodejava/agent/source/PowerSourceFactory.java +++ b/src/main/java/org/noureddine/joular/joularcodejava/agent/source/PowerSourceFactory.java @@ -26,9 +26,8 @@ public static PowerSource getPowerSource(AgentProperties properties) { logger.log(Level.INFO, () -> "Selecting power source type: " + type); try { return switch (normalizedType) { - case "csv" -> new JoularCoreCSVSource(properties.getJoularCoreCsvPath()); - case "http" -> new JoularCoreHttpSource(properties.getJoularCoreHttpUrl()); - case "ringbuffer" -> new JoularCoreRingBufferSource(properties.getRingBufferPath()); + case "csv" -> new PowerJoularCsvSource(properties.getPowerJoularCsvPath()); + case "ringbuffer" -> new PowerJoularRingBufferSource(properties.getPowerJoularRingBufferPath()); case "rapl" -> new LinuxRaplPowerSource(); default -> { logger.log(Level.SEVERE, () -> "Unknown power source type: " + type); diff --git a/src/main/java/org/noureddine/joular/joularcodejava/agent/utils/AgentProperties.java b/src/main/java/org/noureddine/joular/joularcodejava/agent/utils/AgentProperties.java index be07c5a..2d0faf1 100644 --- a/src/main/java/org/noureddine/joular/joularcodejava/agent/utils/AgentProperties.java +++ b/src/main/java/org/noureddine/joular/joularcodejava/agent/utils/AgentProperties.java @@ -21,6 +21,7 @@ import java.util.ArrayList; import java.util.Collections; import java.util.List; +import java.util.Locale; import java.util.Properties; import java.util.logging.Level; import java.util.logging.Logger; @@ -57,25 +58,26 @@ public String getPowerSourceType() { return getStringProperty("power-source-type", "ringbuffer"); } - public String getJoularCoreCsvPath() { - return getStringProperty("joular-core-csv-path", "joularcore-data.csv"); + public String getPowerJoularCsvPath() { + return getStringProperty("powerjoular-csv-path", "powerjoular-data.csv"); } - public String getJoularCoreHttpUrl() { - return getStringProperty("joular-core-http-url", "http://localhost:8080/data"); - } - - public String getRingBufferPath() { + public String getPowerJoularRingBufferPath() { String defaultPath; - String os = System.getProperty("os.name").toLowerCase(); + String os = System.getProperty("os.name").toLowerCase(Locale.ROOT); if (os.contains("win")) { - defaultPath = "Local\\JoularCoreRing"; + // PowerJoular puts the ring buffer under ProgramData so that any user can read it. + String programData = System.getenv("PROGRAMDATA"); + if (programData == null || programData.trim().isEmpty()) { + programData = "C:\\ProgramData"; + } + defaultPath = programData + "\\joularcorering"; } else if (os.contains("mac")) { defaultPath = "/tmp/joularcorering"; } else { defaultPath = "/dev/shm/joularcorering"; } - return getStringProperty("joular-core-ringbuffer-path", defaultPath); + return getStringProperty("powerjoular-ringbuffer-path", defaultPath); } public long getSampleRateMs() { diff --git a/src/main/resources/joularcodejava.properties b/src/main/resources/joularcodejava.properties index a5472ec..1b87cb3 100644 --- a/src/main/resources/joularcodejava.properties +++ b/src/main/resources/joularcodejava.properties @@ -9,29 +9,25 @@ # Joular Code - Java: Configuration # -# Power source type from Joular Core +# Power source type # Options: -# csv (read power from CSV file) -# http (read from HTTP endpoint/websockets) -# ringbuffer (read from shared memory ring buffer, IPC) -# rapl (read from RAPL on Linux directly, without Joular Core) +# ringbuffer (read from the shared memory ring buffer PowerJoular writes with -r) +# csv (read from the CSV file PowerJoular writes with -f or -o) +# rapl (read from RAPL on Linux directly, without PowerJoular) # Default if left empty: ringbuffer power-source-type= -# Path to Joular Core CSV file (if power-source-type is csv) -# Default if left empty: joularcore-data.csv (relative to working directory) -joular-core-csv-path= +# Path to the PowerJoular CSV file (if power-source-type is csv) +# This must be the file holding the power of the whole system, not the one PowerJoular writes for a monitored process +# Default if left empty: powerjoular-data.csv (relative to working directory) +powerjoular-csv-path= -# Joular Core API URL (if power-source-type is http) -# Default if left empty: http://localhost:8080/data -joular-core-http-url= - -# Path to ring buffer (if power-source-type is ringbuffer) +# Path to the ring buffer (if power-source-type is ringbuffer) # Default if left empty (OS-dependent): # Linux: /dev/shm/joularcorering # macOS: /tmp/joularcorering -# Windows: Local\\JoularCoreRing -joular-core-ringbuffer-path= +# Windows: %PROGRAMDATA%\\joularcorering +powerjoular-ringbuffer-path= # The sample rate (milliseconds) for the agent to monitor methods usage # by calling the JVM stack diff --git a/src/test/java/org/noureddine/joular/joularcodejava/agent/source/JoularCoreCSVSourceTest.java b/src/test/java/org/noureddine/joular/joularcodejava/agent/source/JoularCoreCSVSourceTest.java deleted file mode 100644 index 76f6586..0000000 --- a/src/test/java/org/noureddine/joular/joularcodejava/agent/source/JoularCoreCSVSourceTest.java +++ /dev/null @@ -1,320 +0,0 @@ -/* - * Copyright (c) 2026, Adel Noureddine. - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the - * GNU Lesser General Public License v3.0 (LGPL-3.0-only) - * which accompanies this distribution, and is available at - * https://www.gnu.org/licenses/lgpl-3.0.en.html - * - * Author : Adel Noureddine - */ - -package org.noureddine.joular.joularcodejava.agent.source; - -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; - -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.attribute.FileTime; -import java.util.logging.Level; -import java.util.logging.Logger; - -import static org.junit.jupiter.api.Assertions.*; - -/** - * Unit tests for {@link JoularCoreCSVSource}. - * - *

The CSV source reads power data written by Joular Core into a rolling CSV file. - * Tests are grouped into three areas: - *

    - *
  • {@code parseCpuPower} — parsing of individual CSV rows, including rejection of - * non-finite and negative values.
  • - *
  • {@code isHeaderLine} — structural detection of header/comment rows based on - * whether the first column is numeric.
  • - *
  • {@code getCurrentPower} — end-to-end file reading, caching by size+mtime, and - * the last-known-power fallback on parse errors.
  • - *
- * - *

File-based tests use JUnit 5 {@code @TempDir} for full isolation. - */ -class JoularCoreCSVSourceTest { - - @TempDir - Path tempDir; - - // ------------------------------------------------------------------------- - // parseCpuPower - // ------------------------------------------------------------------------- - - /** - * A well-formed CSV row with a positive numeric second column must return - * that value as a {@code Double}. Column indices are zero-based, so the - * cpu_power field lives at index 1. - */ - @Test - void parseCpuPower_validLine_returnsValue() { - assertEquals(15.5, JoularCoreCSVSource.parseCpuPower("1000,15.5,extra"), 1e-9); - } - - /** - * Zero is a valid (non-negative, finite) power reading and must be - * returned as-is. The guard is {@code value < 0}, so exactly zero passes. - */ - @Test - void parseCpuPower_zeroValue_returnsZero() { - assertEquals(0.0, JoularCoreCSVSource.parseCpuPower("1000,0.0,x"), 1e-9); - } - - /** - * A negative power value is physically impossible for CPU power and must - * be rejected by returning {@code null}. - */ - @Test - void parseCpuPower_negativeValue_returnsNull() { - assertNull(JoularCoreCSVSource.parseCpuPower("1000,-5.0,x")); - } - - /** - * {@code Double.parseDouble("Infinity")} succeeds in Java, so the - * {@code !Double.isFinite(value)} guard is necessary to reject it. - */ - @Test - void parseCpuPower_infinityValue_returnsNull() { - assertNull(JoularCoreCSVSource.parseCpuPower("1000,Infinity,x")); - } - - /** - * {@code Double.parseDouble("NaN")} succeeds in Java, so the - * {@code !Double.isFinite(value)} guard must also reject NaN. - */ - @Test - void parseCpuPower_nanValue_returnsNull() { - assertNull(JoularCoreCSVSource.parseCpuPower("1000,NaN,x")); - } - - /** - * A non-numeric second column (e.g., a label or corrupted data) must - * cause a {@code NumberFormatException} that is caught and converted to - * a {@code null} return value. - */ - @Test - void parseCpuPower_nonNumericValue_returnsNull() { - assertNull(JoularCoreCSVSource.parseCpuPower("1000,abc,x")); - } - - /** - * When the second column is present but empty, the parser must return - * {@code null} rather than throw. An empty field after trim means there - * is nothing to parse. - */ - @Test - void parseCpuPower_emptySecondColumn_returnsNull() { - assertNull(JoularCoreCSVSource.parseCpuPower("1000,,x")); - } - - /** - * A line with only one column (no comma) means {@code parts.length <= 1} - * and there is no second field to parse — must return {@code null}. - */ - @Test - void parseCpuPower_singleColumn_returnsNull() { - assertNull(JoularCoreCSVSource.parseCpuPower("1000")); - } - - /** - * Whitespace around the numeric value must be stripped by {@code trim()} - * before parsing so that formatting differences in Joular Core output do - * not cause spurious failures. - */ - @Test - void parseCpuPower_whitespaceAroundValue_trimmedCorrectly() { - assertEquals(7.77, JoularCoreCSVSource.parseCpuPower("1000, 7.77 ,x"), 1e-9); - } - - // ------------------------------------------------------------------------- - // isHeaderLine - // ------------------------------------------------------------------------- - - /** - * A data row whose first column is an integer timestamp must not be - * classified as a header; {@code Double.parseDouble} succeeds → {@code false}. - */ - @Test - void isHeaderLine_numericFirstColumn_false() { - assertFalse(JoularCoreCSVSource.isHeaderLine("1234567890,5.0")); - } - - /** - * A row whose first column is a word (e.g., "timestamp") is the - * column-name header row; {@code Double.parseDouble} throws → {@code true}. - */ - @Test - void isHeaderLine_textFirstColumn_true() { - assertTrue(JoularCoreCSVSource.isHeaderLine("timestamp,cpu_power")); - } - - /** - * A row that starts with a comma has an empty first column. An empty - * string fails {@code Double.parseDouble}, so it is treated as a header. - */ - @Test - void isHeaderLine_emptyFirstColumn_true() { - assertTrue(JoularCoreCSVSource.isHeaderLine(",5.0")); - } - - /** - * A floating-point timestamp (e.g., from a higher-resolution clock) is - * still a valid numeric first column and must not be misidentified as a - * header. - */ - @Test - void isHeaderLine_floatFirstColumn_false() { - assertFalse(JoularCoreCSVSource.isHeaderLine("1234.56,5.0")); - } - - /** - * A line with no comma at all is treated as a single-column line. - * If that single value is numeric, the line is a data row, not a header. - */ - @Test - void isHeaderLine_noCommaNumericWholeLine_false() { - assertFalse(JoularCoreCSVSource.isHeaderLine("9876")); - } - - /** - * A single-column non-numeric line (e.g., a standalone label) must be - * classified as a header. - */ - @Test - void isHeaderLine_noCommaTextWholeLine_true() { - assertTrue(JoularCoreCSVSource.isHeaderLine("header")); - } - - // ------------------------------------------------------------------------- - // getCurrentPower - // ------------------------------------------------------------------------- - - /** - * When the configured CSV file does not exist, the source must return 0.0 - * (the initial {@code lastKnownPower}) rather than throwing or returning a - * negative sentinel. It must also log a warning rather than propagating an - * exception. - */ - @Test - void getCurrentPower_missingFile_returnsZero() { - JoularCoreCSVSource source = new JoularCoreCSVSource( - tempDir.resolve("nonexistent.csv").toString()); - Logger logger = Logger.getLogger(JoularCoreCSVSource.class.getName()); - Level oldLevel = logger.getLevel(); - logger.setLevel(Level.OFF); - try { - assertEquals(0.0, source.getCurrentPower(), 1e-9); - } finally { - logger.setLevel(oldLevel); - } - } - - /** - * A file containing a valid header row followed by a data row must - * return the cpu_power value from that data row. - */ - @Test - void getCurrentPower_validFile_returnsParsedPower() throws Exception { - Path csv = tempDir.resolve("power.csv"); - Files.writeString(csv, "timestamp,cpu_power\n1000,25.5\n", StandardCharsets.UTF_8); - - JoularCoreCSVSource source = new JoularCoreCSVSource(csv.toString()); - assertEquals(25.5, source.getCurrentPower(), 1e-9); - } - - /** - * When the file's size and last-modified time are identical across two - * consecutive calls, the source must skip the file read and return the - * cached value from the first call. This avoids unnecessary I/O on every - * monitoring tick when Joular Core has not written new data. - * - *

The test verifies this by calling the same source twice on an - * unmodified file and asserting both calls return the same value. - * Additionally it overwrites the file with new content but resets the - * mtime to the original value to confirm the cache key is (size, mtime). - */ - @Test - void getCurrentPower_fileUnchanged_returnsCachedValue() throws Exception { - Path csv = tempDir.resolve("power.csv"); - Files.writeString(csv, "timestamp,cpu_power\n1000,10.0\n", StandardCharsets.UTF_8); - - JoularCoreCSVSource source = new JoularCoreCSVSource(csv.toString()); - double first = source.getCurrentPower(); - assertEquals(10.0, first, 1e-9); - - // Overwrite the file with different content but restore the original mtime - // so the cache key (size, mtime) appears unchanged. - FileTime originalTime = Files.getLastModifiedTime(csv); - Files.writeString(csv, "timestamp,cpu_power\n1000,99.9\n", StandardCharsets.UTF_8); - Files.setLastModifiedTime(csv, originalTime); - - // A fresh source on the already-modified file reads correctly. - JoularCoreCSVSource source2 = new JoularCoreCSVSource(csv.toString()); - double val = source2.getCurrentPower(); - // Calling again on a truly unchanged file must return the cached value. - double val2 = source2.getCurrentPower(); - assertEquals(val, val2, 1e-9, "Repeated call on unchanged file should return same value"); - } - - /** - * A file that contains only a header row (no data rows) must leave - * {@code lastKnownPower} at its initial value of 0.0, because there is - * nothing to parse. - */ - @Test - void getCurrentPower_headerOnlyFile_returnsLastKnown() throws Exception { - Path csv = tempDir.resolve("power.csv"); - Files.writeString(csv, "timestamp,cpu_power\n", StandardCharsets.UTF_8); - - JoularCoreCSVSource source = new JoularCoreCSVSource(csv.toString()); - assertEquals(0.0, source.getCurrentPower(), 1e-9); - } - - /** - * When the file contains multiple data rows, the source must return - * the value from the last non-empty, non-header row — which represents - * the most recent power measurement written by Joular Core. - */ - @Test - void getCurrentPower_fileWithMultipleRows_returnsLastDataRow() throws Exception { - Path csv = tempDir.resolve("power.csv"); - Files.writeString(csv, - "timestamp,cpu_power\n1000,5.0\n2000,12.0\n3000,18.0\n", - StandardCharsets.UTF_8); - - JoularCoreCSVSource source = new JoularCoreCSVSource(csv.toString()); - assertEquals(18.0, source.getCurrentPower(), 1e-9); - } - - /** - * When a new row cannot be parsed (e.g., a corrupted or partially written - * line), the source must return the previously cached valid power value - * rather than 0.0 or throwing. This is the "last-known-power" smoothing - * contract shared by all {@link PowerSource} implementations. - * - *

The test primes the cache by reading a valid file, then waits briefly - * to guarantee the OS updates the mtime, writes an invalid row, and asserts - * the original value is returned. - */ - @Test - void getCurrentPower_parseFailure_returnsLastKnownPower() throws Exception { - Path csv = tempDir.resolve("power.csv"); - // First read: prime lastKnownPower with 7.0. - Files.writeString(csv, "timestamp,cpu_power\n1000,7.0\n", StandardCharsets.UTF_8); - JoularCoreCSVSource source = new JoularCoreCSVSource(csv.toString()); - assertEquals(7.0, source.getCurrentPower(), 1e-9); - - // Sleep briefly so the OS assigns a different mtime to the second write, - // ensuring the cache is invalidated and the file is actually re-read. - Thread.sleep(10); - Files.writeString(csv, "timestamp,cpu_power\n2000,not-a-number\n", StandardCharsets.UTF_8); - assertEquals(7.0, source.getCurrentPower(), 1e-9, "Should return last known power on parse failure"); - } -} diff --git a/src/test/java/org/noureddine/joular/joularcodejava/agent/source/JoularCoreHttpSourceTest.java b/src/test/java/org/noureddine/joular/joularcodejava/agent/source/JoularCoreHttpSourceTest.java deleted file mode 100644 index 02cc7b7..0000000 --- a/src/test/java/org/noureddine/joular/joularcodejava/agent/source/JoularCoreHttpSourceTest.java +++ /dev/null @@ -1,321 +0,0 @@ -/* - * Copyright (c) 2026, Adel Noureddine. - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the - * GNU Lesser General Public License v3.0 (LGPL-3.0-only) - * which accompanies this distribution, and is available at - * https://www.gnu.org/licenses/lgpl-3.0.en.html - * - * Author : Adel Noureddine - */ - -package org.noureddine.joular.joularcodejava.agent.source; - -import com.sun.net.httpserver.HttpServer; -import org.junit.jupiter.api.Test; - -import java.net.InetSocketAddress; -import java.nio.charset.StandardCharsets; - -import static org.junit.jupiter.api.Assertions.*; - -/** - * Unit tests for {@link JoularCoreHttpSource}. - * - *

No live HTTP server is needed: the tests cover two independently testable - * areas without making network calls: - *

    - *
  • Constructor validation — the constructor parses and validates the - * configured URL at construction time so that misconfiguration is caught at - * agent startup rather than silently at runtime.
  • - *
  • Static JSON helper — {@code extractCpuPower} uses Jackson Core's - * streaming parser, so tests cover valid payloads and rejection of malformed - * or structurally unsupported payloads.
  • - *
  • HTTP fallback behavior — a local JDK HTTP server verifies status - * and body-size handling without an external service.
  • - *
- */ -class JoularCoreHttpSourceTest { - - // ------------------------------------------------------------------------- - // Constructor validation - // ------------------------------------------------------------------------- - - /** - * A {@code null} URL must be rejected immediately at construction time - * with an {@link IllegalArgumentException} rather than producing a - * NullPointerException later during the first HTTP request. - */ - @Test - void constructor_nullUrl_throwsIllegalArgumentException() { - assertThrows(IllegalArgumentException.class, () -> new JoularCoreHttpSource(null)); - } - - /** - * A whitespace-only URL (after trim it becomes empty) must be rejected - * the same way as a {@code null} URL. - */ - @Test - void constructor_emptyUrl_throwsIllegalArgumentException() { - assertThrows(IllegalArgumentException.class, () -> new JoularCoreHttpSource(" ")); - } - - /** - * Only {@code http} and {@code https} schemes are supported. An - * {@code ftp://} URL must be rejected and the exception message must - * mention the accepted schemes so the misconfiguration is easy to diagnose. - */ - @Test - void constructor_ftpScheme_throwsIllegalArgumentException() { - IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, - () -> new JoularCoreHttpSource("ftp://example.com/data")); - assertTrue(ex.getMessage().contains("http") || ex.getMessage().contains("https"), - "Message should mention http/https"); - } - - /** - * A URL that is syntactically valid as a URI but has an empty host must - * be rejected. The input {@code "http://:8080"} parses successfully - * (scheme=http, authority=":8080") yet {@code getHost()} returns an empty - * string, triggering the missing-host guard. - */ - @Test - void constructor_missingHost_throwsIllegalArgumentException() { - // "http://:8080" parses as a valid URI (scheme=http, authority=":8080"), - // but getHost() returns "" → triggers the "missing host" guard. - assertThrows(IllegalArgumentException.class, - () -> new JoularCoreHttpSource("http://:8080")); - } - - /** - * A well-formed {@code http://} URL must be accepted without throwing. - * The source is closed immediately to release the internal executor thread. - */ - @Test - void constructor_validHttpUrl_doesNotThrow() { - assertDoesNotThrow(() -> { - JoularCoreHttpSource source = new JoularCoreHttpSource("http://localhost:8080/data"); - source.close(); - }); - } - - /** - * A well-formed {@code https://} URL must also be accepted, confirming - * that TLS endpoints are supported in addition to plain HTTP. - */ - @Test - void constructor_validHttpsUrl_doesNotThrow() { - assertDoesNotThrow(() -> { - JoularCoreHttpSource source = new JoularCoreHttpSource("https://example.com/power"); - source.close(); - }); - } - - // ------------------------------------------------------------------------- - // extractCpuPower - // ------------------------------------------------------------------------- - - /** - * The minimal valid JSON payload — a single-key object — must yield the - * numeric value associated with {@code "cpu_power"}. - */ - @Test - void extractCpuPower_validJson_returnsValue() { - assertEquals(42.5, JoularCoreHttpSource.extractCpuPower("{\"cpu_power\":42.5}"), 1e-9); - } - - /** - * Extra whitespace around the colon and the value is allowed by JSON and - * must be handled by the parser without returning {@code null}. - */ - @Test - void extractCpuPower_withWhitespace_returnsValue() { - assertEquals(10.0, JoularCoreHttpSource.extractCpuPower("{ \"cpu_power\" : 10.0 }"), 1e-9); - } - - /** - * A negative power reading is physically invalid and must be rejected - * by returning {@code null}. The guard is {@code value < 0}. - */ - @Test - void extractCpuPower_negativeValue_returnsNull() { - assertNull(JoularCoreHttpSource.extractCpuPower("{\"cpu_power\":-1.0}")); - } - - /** - * Zero watts is a valid (non-negative, finite) power value and must be - * returned. The guard is strictly {@code < 0}, so exactly zero passes. - */ - @Test - void extractCpuPower_zeroValue_returnsZero() { - assertEquals(0.0, JoularCoreHttpSource.extractCpuPower("{\"cpu_power\":0.0}"), 1e-9); - } - - /** - * When the {@code "cpu_power"} key is absent, the parser must return - * {@code null}. - */ - @Test - void extractCpuPower_missingKey_returnsNull() { - assertNull(JoularCoreHttpSource.extractCpuPower("{\"other\":1}")); - } - - /** - * Reinforces the substring-guard requirement at the higher level: if the - * JSON only contains {@code "last_cpu_power"} and not a top-level - * {@code "cpu_power"}, the method must return {@code null}. - */ - @Test - void extractCpuPower_substringKeyOnly_returnsNull() { - assertNull(JoularCoreHttpSource.extractCpuPower("{\"last_cpu_power\":5.0}")); - } - - /** - * A nested {@code cpu_power} field is not part of the supported Joular Core - * HTTP contract. Only top-level numeric fields are accepted. - */ - @Test - void extractCpuPower_nestedCpuPower_returnsNull() { - assertNull(JoularCoreHttpSource.extractCpuPower("{\"outer\":{\"cpu_power\":5.0}}")); - } - - /** - * Malformed JSON must be treated the same as any transient HTTP payload - * problem: no value is parsed, so the caller can keep using last-known power. - */ - @Test - void extractCpuPower_malformedJson_returnsNull() { - assertNull(JoularCoreHttpSource.extractCpuPower("{\"cpu_power\":")); - } - - /** - * If a payload contains duplicate {@code cpu_power} fields, the first valid - * top-level value is used. Joular Core should not produce duplicates, but this - * deterministic behavior avoids order-dependent surprises. - */ - @Test - void extractCpuPower_duplicateField_returnsFirstValue() { - assertEquals(4.0, JoularCoreHttpSource.extractCpuPower( - "{\"cpu_power\":4.0,\"cpu_power\":9.0}"), 1e-9); - } - - /** - * Scientific notation (lowercase {@code e}) is a standard JSON number - * format and must be parsed correctly by {@code Double.parseDouble}. - * {@code 1.5e1} equals {@code 15.0}. - */ - @Test - void extractCpuPower_scientificNotation_parsed() { - assertEquals(15.0, JoularCoreHttpSource.extractCpuPower("{\"cpu_power\":1.5e1}"), 1e-9); - } - - /** - * Scientific notation with an uppercase {@code E} must also be accepted. - * {@code 2E2} equals {@code 200.0}. - */ - @Test - void extractCpuPower_scientificNotationUppercase_parsed() { - assertEquals(200.0, JoularCoreHttpSource.extractCpuPower("{\"cpu_power\":2E2}"), 1e-9); - } - - /** - * When the value associated with {@code "cpu_power"} is a quoted string - * rather than a number, the first character after the colon is {@code "} - * which does not match any character in the numeric scanning loop. The - * loop terminates immediately with {@code start == i}, and the method - * must return {@code null}. - */ - @Test - void extractCpuPower_nonNumericValue_returnsNull() { - // The character after ':' is '"' — not a digit, sign, dot, or exponent marker. - assertNull(JoularCoreHttpSource.extractCpuPower("{\"cpu_power\":\"text\"}")); - } - - /** - * When the JSON object contains multiple power fields, only the value - * associated with the structurally correct {@code "cpu_power"} key must - * be returned; other fields must be ignored. - */ - @Test - void extractCpuPower_multipleKeys_correctKeyReturned() { - assertEquals(7.0, JoularCoreHttpSource.extractCpuPower( - "{\"gpu_power\":3.0,\"cpu_power\":7.0,\"total\":10.0}"), 1e-9); - } - - // ------------------------------------------------------------------------- - // HTTP fallback behavior - // ------------------------------------------------------------------------- - - @Test - void getCurrentPower_non200Status_returnsLastKnownPower() throws Exception { - HttpServer server = startServer(503, "{\"cpu_power\":9.0}"); - try { - JoularCoreHttpSource source = new JoularCoreHttpSource(serverUrl(server)); - try { - assertEquals(0.0, source.getCurrentPower(), 1e-9); - } finally { - source.close(); - } - } finally { - server.stop(0); - } - } - - @Test - void getCurrentPower_oversizedBody_returnsLastKnownPower() throws Exception { - String oversized = "{\"cpu_power\":1.0,\"padding\":\"" + "x".repeat(65 * 1024) + "\"}"; - HttpServer server = startServer(200, oversized); - try { - JoularCoreHttpSource source = new JoularCoreHttpSource(serverUrl(server)); - try { - assertEquals(0.0, source.getCurrentPower(), 1e-9); - } finally { - source.close(); - } - } finally { - server.stop(0); - } - } - - @Test - void getCurrentPower_validThenMalformed_returnsLastKnownPower() throws Exception { - HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); - final int[] calls = {0}; - server.createContext("/data", exchange -> { - calls[0]++; - byte[] response = (calls[0] == 1 ? "{\"cpu_power\":12.5}" : "{\"cpu_power\":") - .getBytes(StandardCharsets.UTF_8); - exchange.sendResponseHeaders(200, response.length); - exchange.getResponseBody().write(response); - exchange.close(); - }); - server.start(); - try { - JoularCoreHttpSource source = new JoularCoreHttpSource(serverUrl(server)); - try { - assertEquals(12.5, source.getCurrentPower(), 1e-9); - assertEquals(12.5, source.getCurrentPower(), 1e-9); - } finally { - source.close(); - } - } finally { - server.stop(0); - } - } - - private static HttpServer startServer(int status, String body) throws Exception { - HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); - server.createContext("/data", exchange -> { - byte[] response = body.getBytes(StandardCharsets.UTF_8); - exchange.sendResponseHeaders(status, response.length); - exchange.getResponseBody().write(response); - exchange.close(); - }); - server.start(); - return server; - } - - private static String serverUrl(HttpServer server) { - return "http://127.0.0.1:" + server.getAddress().getPort() + "/data"; - } -} diff --git a/src/test/java/org/noureddine/joular/joularcodejava/agent/source/JoularCoreRingBufferSourceTest.java b/src/test/java/org/noureddine/joular/joularcodejava/agent/source/JoularCoreRingBufferSourceTest.java deleted file mode 100644 index 165746f..0000000 --- a/src/test/java/org/noureddine/joular/joularcodejava/agent/source/JoularCoreRingBufferSourceTest.java +++ /dev/null @@ -1,262 +0,0 @@ -/* - * Copyright (c) 2026, Adel Noureddine. - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the - * GNU Lesser General Public License v3.0 (LGPL-3.0-only) - * which accompanies this distribution, and is available at - * https://www.gnu.org/licenses/lgpl-3.0.en.html - * - * Author : Adel Noureddine - */ - -package org.noureddine.joular.joularcodejava.agent.source; - -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.condition.EnabledOnOs; -import org.junit.jupiter.api.condition.OS; -import org.junit.jupiter.api.io.CleanupMode; -import org.junit.jupiter.api.io.TempDir; - -import java.nio.ByteBuffer; -import java.nio.ByteOrder; -import java.nio.file.Files; -import java.nio.file.Path; - -import static org.junit.jupiter.api.Assertions.*; - -/** - * Unit tests for {@link JoularCoreRingBufferSource}. - * - *

The ring buffer is a fixed-size shared-memory region written by Joular Core and - * read by this source via a memory-mapped file. The binary layout is: - *

- *   Offset 0      : u64  head (index of the most-recently written entry + 1)
- *   Offset 8      : entry[0]  (48 bytes)
- *   Offset 56     : entry[1]
- *   ...
- *   Each entry:
- *     +0  u64  timestamp
- *     +8  f64  cpu_power   ← the field we read
- *     +16 f64  gpu_power
- *     +24 f64  total_power
- *     +32 f64  cpu_usage
- *     +40 f64  pid_app_power
- * 
- * Total file size: {@code 8 + 5 × 48 = 248} bytes. - * - *

Platform handling

- *

The temp directory is not deleted automatically because Windows can keep a - * mapped file locked until the JVM releases the {@code MappedByteBuffer}. This - * lets the file-mapping tests run on Windows instead of being reported as skipped. - */ -class JoularCoreRingBufferSourceTest { - - /** Entry size in bytes: one u64 timestamp + five f64 values. */ - private static final int ENTRY_SIZE = 48; - - /** Number of entries in the ring buffer. */ - private static final int BUFFER_SIZE = 5; - - /** Total file size: 8-byte head counter followed by BUFFER_SIZE entries. */ - private static final int FILE_SIZE = 8 + BUFFER_SIZE * ENTRY_SIZE; // 248 - - @TempDir(cleanup = CleanupMode.NEVER) - Path tempDir; - - /** - * Creates a 248-byte temp file whose binary layout matches what Joular Core - * would write. The {@code head} value is placed at offset 0; the - * {@code cpuPower} value is placed at the correct offset within entry - * {@code entryIdx}. - * - *

The buffer uses {@link ByteOrder#nativeOrder()} to match the production - * code, which calls {@code buffer.order(ByteOrder.nativeOrder())} after - * mapping the file. - * - * @param head the head counter to write at offset 0 - * @param entryIdx the zero-based slot index (0–4) to store the cpu_power value - * @param cpuPower the cpu_power value to place in the entry - * @return path of the written temp file - */ - private Path writeTempRingBuffer(long head, int entryIdx, double cpuPower) throws Exception { - ByteBuffer buf = ByteBuffer.allocate(FILE_SIZE).order(ByteOrder.nativeOrder()); - buf.putLong(0, head); - // Each entry starts at offset 8 + entryIdx * ENTRY_SIZE; - // cpu_power is the second field (after the 8-byte timestamp), so +8. - int cpuPowerOffset = 8 + entryIdx * ENTRY_SIZE + 8; - buf.putDouble(cpuPowerOffset, cpuPower); - Path file = tempDir.resolve("ring.bin"); - Files.write(file, buf.array()); - return file; - } - - // ------------------------------------------------------------------------- - // Platform-independent tests - // ------------------------------------------------------------------------- - - /** - * When {@code initialize()} has never been called, the internal - * {@code ByteBuffer} is {@code null} and {@code getCurrentPower()} must - * return 0.0 immediately without throwing. - */ - @Test - void getCurrentPower_bufferNull_returnsZero() { - JoularCoreRingBufferSource source = new JoularCoreRingBufferSource("/nonexistent"); - assertEquals(0.0, source.getCurrentPower(), 1e-9); - } - - /** - * After {@code close()} is called, the internal buffer is set to - * {@code null}. A subsequent call to {@code getCurrentPower()} must - * therefore return 0.0 (the null-buffer fast-path) and not throw. - */ - @Test - void close_setsBufferNull_subsequentGetReturnsZero() throws Exception { - Path file = writeTempRingBuffer(1L, 0, 20.0); - JoularCoreRingBufferSource source = new JoularCoreRingBufferSource(file.toString()); - source.initialize(); - // Confirm a successful read before closing. - source.getCurrentPower(); - source.close(); - assertEquals(0.0, source.getCurrentPower(), 1e-9); - } - - // ------------------------------------------------------------------------- - // File-mapping tests - // ------------------------------------------------------------------------- - - /** - * With {@code head = 1}, the ring index is {@code (1 - 1) % 5 = 0}, so - * the cpu_power field lives at byte offset {@code 8 + 0 × 48 + 8 = 16}. - * This is the simplest valid reading and validates the base-case slot - * calculation. - */ - @Test - void getCurrentPower_validHead1_returnsCpuPower() throws Exception { - // head=1 → idx=(1-1)%5=0 → entry at offset 8, cpu_power at offset 16 - Path file = writeTempRingBuffer(1L, 0, 25.0); - JoularCoreRingBufferSource source = new JoularCoreRingBufferSource(file.toString()); - source.initialize(); - assertEquals(25.0, source.getCurrentPower(), 1e-9); - } - - /** - * With {@code head = 3}, the ring index is {@code (3 - 1) % 5 = 2}, so - * the cpu_power field lives at byte offset {@code 8 + 2 × 48 + 8 = 112}. - * This verifies the modulo ring-slot arithmetic for non-zero indices. - */ - @Test - void getCurrentPower_validHead3_returnsCorrectSlot() throws Exception { - // head=3 → idx=(3-1)%5=2 → entry at offset 8+2*48=104, cpu_power at 112 - Path file = writeTempRingBuffer(3L, 2, 18.0); - JoularCoreRingBufferSource source = new JoularCoreRingBufferSource(file.toString()); - source.initialize(); - assertEquals(18.0, source.getCurrentPower(), 1e-9); - } - - /** - * A head value of 0 means Joular Core has not written any entry yet. - * The source must treat this as "no data available" and return - * {@code lastKnownPower} (initially 0.0). - */ - @Test - void getCurrentPower_headZero_returnsZero() throws Exception { - Path file = writeTempRingBuffer(0L, 0, 99.0); - JoularCoreRingBufferSource source = new JoularCoreRingBufferSource(file.toString()); - source.initialize(); - assertEquals(0.0, source.getCurrentPower(), 1e-9); - } - - /** - * A negative head value is outside the valid range ({@code head <= 0} check) - * and must be handled the same as zero — return 0.0 without throwing. - */ - @Test - void getCurrentPower_headNegative_returnsZero() throws Exception { - Path file = writeTempRingBuffer(-1L, 0, 10.0); - JoularCoreRingBufferSource source = new JoularCoreRingBufferSource(file.toString()); - source.initialize(); - assertEquals(0.0, source.getCurrentPower(), 1e-9); - } - - /** - * A negative cpu_power value in the ring buffer is physically impossible - * and must be rejected. The source must return {@code lastKnownPower} - * (0.0 on the first call) rather than propagating the bad reading. - */ - @Test - void getCurrentPower_negativeCpuPower_returnsLastKnown() throws Exception { - Path file = writeTempRingBuffer(1L, 0, -5.0); - JoularCoreRingBufferSource source = new JoularCoreRingBufferSource(file.toString()); - source.initialize(); - assertEquals(0.0, source.getCurrentPower(), 1e-9); - } - - /** - * {@code Double.longBitsToDouble} for a NaN bit pattern is finite=false, - * so the {@code !Double.isFinite(value)} guard must reject it and return - * {@code lastKnownPower} (0.0). - */ - @Test - void getCurrentPower_nanCpuPower_returnsLastKnown() throws Exception { - Path file = writeTempRingBuffer(1L, 0, Double.NaN); - JoularCoreRingBufferSource source = new JoularCoreRingBufferSource(file.toString()); - source.initialize(); - assertEquals(0.0, source.getCurrentPower(), 1e-9); - } - - /** - * Once a valid value has been read, it is cached as {@code lastKnownPower}. - * Calling {@code getCurrentPower()} again on an unmodified buffer must - * return the same value — both because the buffer hasn't changed and - * because the load-verify check (head1 == head2) passes on a static file. - */ - @Test - void getCurrentPower_validThenInvalid_returnsLastKnownGoodValue() throws Exception { - Path file = writeTempRingBuffer(1L, 0, 30.0); - JoularCoreRingBufferSource source = new JoularCoreRingBufferSource(file.toString()); - source.initialize(); - assertEquals(30.0, source.getCurrentPower(), 1e-9); - // Second call on the same static buffer — head is unchanged so - // no torn-read is detected and the cached value is returned. - assertEquals(30.0, source.getCurrentPower(), 1e-9); - } - - /** - * When the mapped file does not exist, {@code initialize()} must propagate - * the {@link java.io.FileNotFoundException} (or equivalent) as an - * {@link Exception} so the agent can fail fast at startup. - */ - @Test - void initialize_missingFile_throwsException() { - JoularCoreRingBufferSource source = new JoularCoreRingBufferSource( - tempDir.resolve("does-not-exist.bin").toString()); - assertThrows(Exception.class, source::initialize); - } - - // ------------------------------------------------------------------------- - // Windows-only: named shared memory fallback - // ------------------------------------------------------------------------- - - /** - * On Windows, when the configured path does not correspond to an existing - * file, {@code initialize()} falls back to opening a named file-mapping - * object via {@code Kernel32.OpenFileMappingW}. If no such mapping exists, - * {@code OpenFileMappingW} returns a null or invalid handle, and the method - * must throw an {@link Exception} rather than silently proceeding with a - * null buffer. - * - *

This test confirms that the Windows code path (JNA call) is reachable - * and behaves correctly when the mapping is absent, without requiring a - * live Joular Core process. - */ - @Test - @EnabledOnOs(OS.WINDOWS) - void initialize_onWindows_sharedMemoryPath_throwsWhenMappingAbsent() { - // A name that exists neither as a file on disk nor as a Windows named - // mapping — forces the fallback to mapWindowsSharedMemory(), which throws. - JoularCoreRingBufferSource source = new JoularCoreRingBufferSource( - "Local\\NonExistentJoularMapping_TestOnly_12345"); - assertThrows(Exception.class, source::initialize); - } -} diff --git a/src/test/java/org/noureddine/joular/joularcodejava/agent/source/PowerJoularCsvSourceTest.java b/src/test/java/org/noureddine/joular/joularcodejava/agent/source/PowerJoularCsvSourceTest.java new file mode 100644 index 0000000..2ed0159 --- /dev/null +++ b/src/test/java/org/noureddine/joular/joularcodejava/agent/source/PowerJoularCsvSourceTest.java @@ -0,0 +1,300 @@ +/* + * Copyright (c) 2026, Adel Noureddine. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the + * GNU Lesser General Public License v3.0 (LGPL-3.0-only) + * which accompanies this distribution, and is available at + * https://www.gnu.org/licenses/lgpl-3.0.en.html + * + * Author : Adel Noureddine + */ + +package org.noureddine.joular.joularcodejava.agent.source; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.*; +import static org.noureddine.joular.joularcodejava.agent.source.PowerJoularCsvSource.SYSTEM_CPU_POWER_INDEX; +import static org.noureddine.joular.joularcodejava.agent.source.PowerSource.MAX_STALE_CYCLES; +import static org.noureddine.joular.joularcodejava.agent.source.TestLogging.quietly; + +/** + * Unit tests for {@link PowerJoularCsvSource}. + * + *

Fixtures are real PowerJoular rows. The system file it writes with {@code -f} or {@code -o} is + * {@code Timestamp,CPU Usage,Total Power,CPU Power,GPU Power}, so CPU Power sits at index 3 and not + * at index 1, which is CPU Usage. + * + *

Timestamps are written as the current time: the source ignores rows older than ten seconds, so + * a fixed timestamp would make every file look like one left behind by a PowerJoular that stopped. + */ +class PowerJoularCsvSourceTest { + + private static final String SYSTEM_HEADER = "Timestamp,CPU Usage,Total Power,CPU Power,GPU Power"; + private static final String TARGET_HEADER = "Timestamp,CPU Usage,CPU Power"; + + @TempDir + Path tempDir; + + private static long now() { + return System.currentTimeMillis() / 1000L; + } + + /** One system row with the given CPU Power and a current timestamp. */ + private static String systemRow(double cpuPower) { + return now() + ",0.2460,18.4500," + cpuPower + ",3.2500"; + } + + /** One row of the file PowerJoular writes for a monitored process. */ + private static String targetRow(double cpuPower) { + return now() + ",0.0310," + cpuPower; + } + + private Path writeCsv(String content) throws Exception { + Path csv = tempDir.resolve("powerjoular.csv"); + Files.writeString(csv, content, StandardCharsets.UTF_8); + return csv; + } + + private static Double parseSystemRow(String row) { + return PowerJoularCsvSource.parseCpuPower(row.split(",", -1), SYSTEM_CPU_POWER_INDEX); + } + + // ------------------------------------------------------------------------- + // parseCpuPower + // ------------------------------------------------------------------------- + + /** + * A well formed system row must give back the value in the CPU Power column, not another one. + * Whitespace around the value is stripped, and zero is a real reading: a CPU that was read and + * drew no measurable power. + */ + @ParameterizedTest(name = "{0} -> {1} W") + @CsvSource(delimiter = '|', value = { + "1756681930,0.2460,18.4500,15.2000,3.2500 | 15.2", + "1756681930,0.0,0.0,0.0,0.0 | 0.0", + "1756681930,0.2,18.4, 7.77 ,3.2 | 7.77", + }) + void parseCpuPower_validRow_returnsCpuPower(String row, double expected) { + assertEquals(expected, parseSystemRow(row), 1e-9); + } + + /** + * Values that are not a power must come back as null rather than throw. PowerJoular writes -1 for + * a value it could not read at all, which is not the same as zero; Infinity and NaN both parse + * successfully in Java, so they need rejecting explicitly; and a row can be corrupted or half + * written. + */ + @ParameterizedTest + @ValueSource(strings = { + "1756681930,0.2460,18.4500,-1.0000,3.2500", // unreadable sentinel + "1756681930,0.2,18.4,Infinity,3.2", + "1756681930,0.2,18.4,NaN,3.2", + "1756681930,0.2,18.4,abc,3.2", + "1756681930,0.2,18.4,,3.2", // empty column + "1756681930,0.2", // row shorter than the index + }) + void parseCpuPower_unusableRow_returnsNull(String row) { + assertNull(parseSystemRow(row)); + } + + /** A column index of -1 stands for a column that could not be worked out. */ + @Test + void parseCpuPower_unresolvedColumn_returnsNull() { + assertNull(PowerJoularCsvSource.parseCpuPower("1756681930,0.2,18.4,15.2,3.2".split(",", -1), -1)); + } + + // ------------------------------------------------------------------------- + // parseTimestamp + // ------------------------------------------------------------------------- + + /** A data row opens with a time, whole or fractional. */ + @ParameterizedTest + @CsvSource(delimiter = '|', value = { + "1756681930 | 1756681930", + "1756681930.5 | 1756681930.5", + " 1756681930 | 1756681930", + }) + void parseTimestamp_numericFirstColumn_returnsTime(String firstColumn, double expected) { + assertEquals(expected, PowerJoularCsvSource.parseTimestamp(firstColumn), 1e-9); + } + + /** Anything that is not a time marks the header row, a comment, or an empty column. */ + @ParameterizedTest + @ValueSource(strings = {"Timestamp", "header", "", " "}) + void parseTimestamp_nonNumericFirstColumn_returnsNull(String firstColumn) { + assertNull(PowerJoularCsvSource.parseTimestamp(firstColumn)); + } + + // ------------------------------------------------------------------------- + // getCurrentPower + // ------------------------------------------------------------------------- + + /** A file that is not there yet must give zero and a warning, not an exception. */ + @Test + void getCurrentPower_missingFile_returnsZero() throws Exception { + PowerJoularCsvSource source = new PowerJoularCsvSource( + tempDir.resolve("nonexistent.csv").toString()); + quietly(PowerJoularCsvSource.class, () -> assertEquals(0.0, source.getCurrentPower(), 1e-9)); + } + + /** The file PowerJoular writes with -f: a header, then rows. */ + @Test + void getCurrentPower_systemFileWithHeader_returnsCpuPower() throws Exception { + Path csv = writeCsv(SYSTEM_HEADER + "\n" + systemRow(15.2) + "\n"); + + PowerJoularCsvSource source = new PowerJoularCsvSource(csv.toString()); + assertEquals(15.2, source.getCurrentPower(), 1e-9); + } + + /** + * The file PowerJoular writes with -o: the latest row only, and no header. The column is worked + * out from the number of columns, which is what makes both forms readable. + */ + @Test + void getCurrentPower_headerlessSystemFile_returnsCpuPower() throws Exception { + Path csv = writeCsv(systemRow(12.5) + "\n"); + + PowerJoularCsvSource source = new PowerJoularCsvSource(csv.toString()); + assertEquals(12.5, source.getCurrentPower(), 1e-9); + } + + /** + * The three column file holds the power of one monitored process, which the agent would scale by + * the share of the machine the JVM is using a second time. It must be refused, not read. + */ + @Test + void getCurrentPower_targetFile_isRejected() throws Exception { + Path csv = writeCsv(targetRow(1.84) + "\n"); + + PowerJoularCsvSource source = new PowerJoularCsvSource(csv.toString()); + quietly(PowerJoularCsvSource.class, () -> assertEquals(0.0, source.getCurrentPower(), 1e-9, + "The per-process file must be refused rather than scaled twice")); + } + + /** + * The same refusal when the source sees the header before any row has been written, which is what + * {@code powerjoular -f} does: it creates the file with its header and adds the first row a second + * later. Reading the header first must not teach the source a column and so talk it past the + * refusal on the following cycle. + */ + @Test + void getCurrentPower_targetFileHeaderSeenFirst_isStillRejected() throws Exception { + Path csv = writeCsv(TARGET_HEADER + "\n"); + + PowerJoularCsvSource source = new PowerJoularCsvSource(csv.toString()); + quietly(PowerJoularCsvSource.class, () -> { + assertEquals(0.0, source.getCurrentPower(), 1e-9, "A header on its own carries no power"); + + Files.writeString(csv, TARGET_HEADER + "\n" + targetRow(1.84) + "\n", StandardCharsets.UTF_8); + assertEquals(0.0, source.getCurrentPower(), 1e-9, + "The row must still be refused once it arrives"); + }); + } + + /** A file holding only its header has nothing to read yet. */ + @Test + void getCurrentPower_headerOnlyFile_returnsZero() throws Exception { + Path csv = writeCsv(SYSTEM_HEADER + "\n"); + + PowerJoularCsvSource source = new PowerJoularCsvSource(csv.toString()); + assertEquals(0.0, source.getCurrentPower(), 1e-9); + } + + /** With several rows, the last one is the newest measurement. */ + @Test + void getCurrentPower_multipleRows_returnsLastRow() throws Exception { + Path csv = writeCsv(SYSTEM_HEADER + "\n" + + systemRow(5.0) + "\n" + + systemRow(12.0) + "\n" + + systemRow(18.0) + "\n"); + + PowerJoularCsvSource source = new PowerJoularCsvSource(csv.toString()); + assertEquals(18.0, source.getCurrentPower(), 1e-9); + } + + /** + * A file left behind by a PowerJoular that has stopped still parses, so it is the timestamp that + * has to rule it out. + */ + @Test + void getCurrentPower_staleRow_returnsZero() throws Exception { + Path csv = writeCsv(SYSTEM_HEADER + "\n" + (now() - 3600) + ",0.2460,18.4500,15.2000,3.2500\n"); + + PowerJoularCsvSource source = new PowerJoularCsvSource(csv.toString()); + quietly(PowerJoularCsvSource.class, () -> assertEquals(0.0, source.getCurrentPower(), 1e-9, + "A row an hour old must not be taken for a current measurement")); + } + + /** A row that cannot be parsed is covered by the last value we trust. */ + @Test + void getCurrentPower_parseFailure_returnsLastKnownPower() throws Exception { + Path csv = writeCsv(SYSTEM_HEADER + "\n" + systemRow(7.0) + "\n"); + PowerJoularCsvSource source = new PowerJoularCsvSource(csv.toString()); + assertEquals(7.0, source.getCurrentPower(), 1e-9); + + Files.writeString(csv, SYSTEM_HEADER + "\n" + now() + ",0.2460,18.4500,not-a-number,3.2500\n", + StandardCharsets.UTF_8); + assertEquals(7.0, source.getCurrentPower(), 1e-9, + "A single unreadable cycle must be covered by the last known power"); + } + + /** In overwrite mode PowerJoular empties the file before writing, so a read can land on nothing. */ + @Test + void getCurrentPower_emptyFile_returnsLastKnownPower() throws Exception { + Path csv = writeCsv(systemRow(9.5) + "\n"); + PowerJoularCsvSource source = new PowerJoularCsvSource(csv.toString()); + assertEquals(9.5, source.getCurrentPower(), 1e-9); + + Files.writeString(csv, "", StandardCharsets.UTF_8); + assertEquals(9.5, source.getCurrentPower(), 1e-9); + } + + /** + * The cover the last known value gives is bounded: once PowerJoular has been quiet for long + * enough, the source reports zero rather than going on replaying a value that is no longer true. + */ + @Test + void getCurrentPower_afterGraceWindow_returnsZero() throws Exception { + Path csv = writeCsv(SYSTEM_HEADER + "\n" + systemRow(20.0) + "\n"); + PowerJoularCsvSource source = new PowerJoularCsvSource(csv.toString()); + assertEquals(20.0, source.getCurrentPower(), 1e-9); + + Files.delete(csv); + + quietly(PowerJoularCsvSource.class, () -> { + for (int cycle = 1; cycle <= MAX_STALE_CYCLES; cycle++) { + assertEquals(20.0, source.getCurrentPower(), 1e-9, + "Cycle " + cycle + " is still within the grace window"); + } + assertEquals(0.0, source.getCurrentPower(), 1e-9, + "Past the grace window the source must stop attributing energy"); + }); + } + + /** A producer that comes back must be picked up again, with the stale state cleared. */ + @Test + void getCurrentPower_producerReturns_recovers() throws Exception { + Path csv = writeCsv(SYSTEM_HEADER + "\n" + systemRow(20.0) + "\n"); + PowerJoularCsvSource source = new PowerJoularCsvSource(csv.toString()); + assertEquals(20.0, source.getCurrentPower(), 1e-9); + + Files.delete(csv); + quietly(PowerJoularCsvSource.class, () -> { + for (int cycle = 0; cycle <= MAX_STALE_CYCLES; cycle++) { + source.getCurrentPower(); + } + }); + + Files.writeString(csv, SYSTEM_HEADER + "\n" + systemRow(33.0) + "\n", StandardCharsets.UTF_8); + assertEquals(33.0, source.getCurrentPower(), 1e-9); + } +} diff --git a/src/test/java/org/noureddine/joular/joularcodejava/agent/source/PowerJoularRingBufferSourceTest.java b/src/test/java/org/noureddine/joular/joularcodejava/agent/source/PowerJoularRingBufferSourceTest.java new file mode 100644 index 0000000..ea6c805 --- /dev/null +++ b/src/test/java/org/noureddine/joular/joularcodejava/agent/source/PowerJoularRingBufferSourceTest.java @@ -0,0 +1,263 @@ +/* + * Copyright (c) 2026, Adel Noureddine. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the + * GNU Lesser General Public License v3.0 (LGPL-3.0-only) + * which accompanies this distribution, and is available at + * https://www.gnu.org/licenses/lgpl-3.0.en.html + * + * Author : Adel Noureddine + */ + +package org.noureddine.joular.joularcodejava.agent.source; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.*; +import static org.noureddine.joular.joularcodejava.agent.source.PowerJoularRingBufferSource.ENTRY_SIZE; +import static org.noureddine.joular.joularcodejava.agent.source.PowerJoularRingBufferSource.FILE_SIZE; +import static org.noureddine.joular.joularcodejava.agent.source.PowerSource.MAX_STALE_CYCLES; +import static org.noureddine.joular.joularcodejava.agent.source.TestLogging.quietly; + +/** + * Unit tests for {@link PowerJoularRingBufferSource}. + * + *

Fixtures reproduce the area PowerJoular writes with {@code -r}, in the byte order of the + * machine: + * + *

+ *   Offset 0      : u64  head
+ *   Offset 8      : entry[0]  (48 bytes)
+ *   Offset 56     : entry[1]
+ *   ...
+ *   Each entry:
+ *     +0  u64  timestamp     (Unix time in seconds)
+ *     +8  f64  cpu_power     (watts)
+ *     +16 f64  gpu_power
+ *     +24 f64  total_power
+ *     +32 f64  cpu_usage
+ *     +40 f64  pid_app_power
+ *
+ *   Total size: 8 + 5 * 48 = 248 bytes.
+ * 
+ * + *

The sizes come from the class under test rather than from copies, so that a change to the layout + * cannot leave the fixtures quietly asserting against the old one. + * + *

Entries carry the current time: the source ignores entries older than ten seconds, so a fixed + * timestamp would make every fixture look like an area left behind by a PowerJoular that stopped. + */ +class PowerJoularRingBufferSourceTest { + + @TempDir + Path tempDir; + + private static long now() { + return System.currentTimeMillis() / 1000L; + } + + /** An area holding one entry with the current time. */ + private Path writeTempRingBuffer(long head, int entryIdx, double cpuPower) throws Exception { + return writeTempRingBuffer(head, entryIdx, cpuPower, now()); + } + + private Path writeTempRingBuffer(long head, int entryIdx, double cpuPower, long timestamp) + throws Exception { + ByteBuffer buf = ByteBuffer.allocate(FILE_SIZE).order(ByteOrder.nativeOrder()); + buf.putLong(0, head); + int entryOffset = 8 + entryIdx * ENTRY_SIZE; + buf.putLong(entryOffset, timestamp); + buf.putDouble(entryOffset + 8, cpuPower); + Path file = tempDir.resolve("ring.bin"); + Files.write(file, buf.array()); + return file; + } + + private PowerJoularRingBufferSource sourceFor(Path file) { + PowerJoularRingBufferSource source = new PowerJoularRingBufferSource(file.toString()); + source.initialize(); + return source; + } + + // ------------------------------------------------------------------------- + // Reading an entry + // ------------------------------------------------------------------------- + + /** + * The counter says how many cycles have been written, so the newest entry is {@code (head-1) mod 5} + * and its cpu_power sits 8 bytes into it. A counter of zero or below means PowerJoular has not + * written a cycle yet, and -1 is what it writes for a value it could not read at all. + */ + @ParameterizedTest(name = "head={0} entry={1} cpu_power={2} -> {3} W") + @CsvSource({ + // head, entryIdx, cpuPower, expected + " 1, 0, 25.0, 25.0", // first cycle + " 3, 2, 18.0, 18.0", // (3-1) % 5 = 2 + " 7, 1, 11.5, 11.5", // the counter wraps round the ring + " 0, 0, 99.0, 0.0", // nothing written yet + " -1, 0, 10.0, 0.0", // a counter outside its range + " 1, 0, -1.0, 0.0", // the unreadable sentinel + " 1, 0, NaN, 0.0", // not a finite power + }) + void getCurrentPower_readsNewestEntry(long head, int entryIdx, double cpuPower, double expected) + throws Exception { + PowerJoularRingBufferSource source = sourceFor(writeTempRingBuffer(head, entryIdx, cpuPower)); + assertEquals(expected, source.getCurrentPower(), 1e-9); + } + + /** Reading the same unchanged area twice gives the same value. */ + @Test + void getCurrentPower_calledTwice_returnsSameValue() throws Exception { + PowerJoularRingBufferSource source = sourceFor(writeTempRingBuffer(1L, 0, 30.0)); + assertEquals(30.0, source.getCurrentPower(), 1e-9); + assertEquals(30.0, source.getCurrentPower(), 1e-9); + } + + // ------------------------------------------------------------------------- + // Freshness + // ------------------------------------------------------------------------- + + /** + * The area outlives PowerJoular on purpose, so an old one still holds a plausible counter and a + * plausible power. It is the timestamp that has to rule it out. A timestamp ahead of us means the + * clock stepped, which is not staleness. + */ + @ParameterizedTest(name = "entry {0}s old -> {1} W") + @CsvSource({ + " 2, 16.0", // just read + " -60, 16.0", // the clock stepped backwards + " 3600, 0.0", // an hour old: PowerJoular has stopped + }) + void getCurrentPower_judgesFreshnessOnTheTimestamp(long ageSeconds, double expected) + throws Exception { + Path file = writeTempRingBuffer(1L, 0, 16.0, now() - ageSeconds); + PowerJoularRingBufferSource source = sourceFor(file); + quietly(PowerJoularRingBufferSource.class, + () -> assertEquals(expected, source.getCurrentPower(), 1e-9)); + } + + // ------------------------------------------------------------------------- + // Attaching + // ------------------------------------------------------------------------- + + /** Reading before initialize must not throw. */ + @Test + void getCurrentPower_neverInitialized_returnsZero() throws Exception { + PowerJoularRingBufferSource source = new PowerJoularRingBufferSource( + tempDir.resolve("nonexistent").toString()); + quietly(PowerJoularRingBufferSource.class, + () -> assertEquals(0.0, source.getCurrentPower(), 1e-9)); + } + + /** + * A missing area is not fatal: PowerJoular may not have started yet, so the agent must come up + * rather than abort, and must pick the area up once it appears. + */ + @Test + void getCurrentPower_fileAppearsLater_attaches() throws Exception { + Path file = tempDir.resolve("ring.bin"); + PowerJoularRingBufferSource source = new PowerJoularRingBufferSource(file.toString()); + quietly(PowerJoularRingBufferSource.class, () -> { + assertDoesNotThrow(source::initialize); + assertEquals(0.0, source.getCurrentPower(), 1e-9); + }); + + writeTempRingBuffer(1L, 0, 21.0); + assertEquals(21.0, source.getCurrentPower(), 1e-9); + } + + /** + * A file shorter than the area is one PowerJoular has created but not yet sized. Mapping past its + * end would fault on access rather than fail at the mapping, so the length is checked first, and + * the area is read once it grows. + */ + @Test + void getCurrentPower_shortFileThenSized_attaches() throws Exception { + Path file = tempDir.resolve("ring.bin"); + Files.write(file, new byte[64]); + + PowerJoularRingBufferSource source = new PowerJoularRingBufferSource(file.toString()); + quietly(PowerJoularRingBufferSource.class, () -> { + source.initialize(); + assertEquals(0.0, source.getCurrentPower(), 1e-9); + }); + + writeTempRingBuffer(1L, 0, 27.5); + assertEquals(27.5, source.getCurrentPower(), 1e-9); + } + + // ------------------------------------------------------------------------- + // The grace window + // ------------------------------------------------------------------------- + + /** + * The cover the last known value gives is bounded: once PowerJoular has been quiet for long + * enough, the source reports zero rather than going on replaying a value that is no longer true. + */ + @Test + void getCurrentPower_afterGraceWindow_returnsZero() throws Exception { + long now = now(); + PowerJoularRingBufferSource source = sourceFor(writeTempRingBuffer(1L, 0, 20.0, now)); + assertEquals(20.0, source.getCurrentPower(), 1e-9); + + // The same area, with its entry aged past the limit: PowerJoular has stopped writing. + writeTempRingBuffer(1L, 0, 20.0, now - 3600); + + quietly(PowerJoularRingBufferSource.class, () -> { + for (int cycle = 1; cycle <= MAX_STALE_CYCLES; cycle++) { + assertEquals(20.0, source.getCurrentPower(), 1e-9, + "Cycle " + cycle + " is still within the grace window"); + } + assertEquals(0.0, source.getCurrentPower(), 1e-9, + "Past the grace window the source must stop attributing energy"); + }); + } + + /** A producer that comes back must be picked up again, with the stale state cleared. */ + @Test + void getCurrentPower_producerReturns_recovers() throws Exception { + long now = now(); + PowerJoularRingBufferSource source = sourceFor(writeTempRingBuffer(1L, 0, 20.0, now)); + assertEquals(20.0, source.getCurrentPower(), 1e-9); + + writeTempRingBuffer(1L, 0, 20.0, now - 3600); + quietly(PowerJoularRingBufferSource.class, () -> { + for (int cycle = 0; cycle <= MAX_STALE_CYCLES; cycle++) { + source.getCurrentPower(); + } + }); + + writeTempRingBuffer(2L, 1, 35.0); + assertEquals(35.0, source.getCurrentPower(), 1e-9); + } + + // ------------------------------------------------------------------------- + // Closing + // ------------------------------------------------------------------------- + + /** Reading after close must not throw. */ + @Test + void close_thenGetCurrentPower_returnsZero() throws Exception { + PowerJoularRingBufferSource source = sourceFor(writeTempRingBuffer(1L, 0, 20.0)); + assertEquals(20.0, source.getCurrentPower(), 1e-9); + + source.close(); + // Closing drops the mapping; the next read re-attaches, which is harmless. + quietly(PowerJoularRingBufferSource.class, () -> assertDoesNotThrow(source::getCurrentPower)); + } + + /** The contract on {@link PowerSource} says close must be safe even if initialize never ran. */ + @Test + void close_withoutInitialize_isSafe() { + PowerJoularRingBufferSource source = new PowerJoularRingBufferSource("/nonexistent"); + assertDoesNotThrow(source::close); + } +} diff --git a/src/test/java/org/noureddine/joular/joularcodejava/agent/source/PowerSourceFactoryTest.java b/src/test/java/org/noureddine/joular/joularcodejava/agent/source/PowerSourceFactoryTest.java index 0ff43b3..b623e08 100644 --- a/src/test/java/org/noureddine/joular/joularcodejava/agent/source/PowerSourceFactoryTest.java +++ b/src/test/java/org/noureddine/joular/joularcodejava/agent/source/PowerSourceFactoryTest.java @@ -75,7 +75,7 @@ private AgentProperties propsWithContent(String content) throws Exception { // ------------------------------------------------------------------------- /** - * {@code power-source-type=csv} must produce a {@link JoularCoreCSVSource} + * {@code power-source-type=csv} must produce a {@link PowerJoularCsvSource} * instance. The CSV path falls back to the default value; no further * configuration is needed. */ @@ -84,27 +84,12 @@ void getPowerSource_csv_returnsCSVSource() throws Exception { AgentProperties props = propsWithContent("power-source-type=csv\n"); PowerSource source = PowerSourceFactory.getPowerSource(props); assertNotNull(source); - assertInstanceOf(JoularCoreCSVSource.class, source); - } - - /** - * {@code power-source-type=http} with a valid HTTP URL must produce a - * {@link JoularCoreHttpSource} instance. The source is closed after the - * assertion to release the internal executor thread. - */ - @Test - void getPowerSource_http_returnsHttpSource() throws Exception { - AgentProperties props = propsWithContent( - "power-source-type=http\njoular-core-http-url=http://localhost:9999/data\n"); - PowerSource source = PowerSourceFactory.getPowerSource(props); - assertNotNull(source); - assertInstanceOf(JoularCoreHttpSource.class, source); - source.close(); + assertInstanceOf(PowerJoularCsvSource.class, source); } /** * {@code power-source-type=ringbuffer} must produce a - * {@link JoularCoreRingBufferSource} instance. The path falls back to the + * {@link PowerJoularRingBufferSource} instance. The path falls back to the * OS-appropriate default; no file needs to exist for construction. */ @Test @@ -112,7 +97,7 @@ void getPowerSource_ringbuffer_returnsRingBufferSource() throws Exception { AgentProperties props = propsWithContent("power-source-type=ringbuffer\n"); PowerSource source = PowerSourceFactory.getPowerSource(props); assertNotNull(source); - assertInstanceOf(JoularCoreRingBufferSource.class, source); + assertInstanceOf(PowerJoularRingBufferSource.class, source); } /** @@ -137,7 +122,7 @@ void getPowerSource_csvUppercase_returnsCSVSource() throws Exception { AgentProperties props = propsWithContent("power-source-type=CSV\n"); PowerSource source = PowerSourceFactory.getPowerSource(props); assertNotNull(source); - assertInstanceOf(JoularCoreCSVSource.class, source); + assertInstanceOf(PowerJoularCsvSource.class, source); } // ------------------------------------------------------------------------- @@ -154,36 +139,4 @@ void getPowerSource_unknownType_returnsNull() throws Exception { AgentProperties props = propsWithContent("power-source-type=grpc\n"); assertNull(PowerSourceFactory.getPowerSource(props)); } - - /** - * When the HTTP URL has an invalid scheme (e.g., {@code ftp://}), the - * {@link JoularCoreHttpSource} constructor throws an - * {@link IllegalArgumentException}. The factory must catch it and return - * {@code null} so that the agent can fail fast with a clear log message. - */ - @Test - void getPowerSource_invalidHttpUrl_returnsNull() throws Exception { - AgentProperties props = propsWithContent( - "power-source-type=http\njoular-core-http-url=ftp://bad-scheme\n"); - assertNull(PowerSourceFactory.getPowerSource(props)); - } - - /** - * A URL without a scheme (e.g., {@code "localhost:8080"}) is parsed by - * {@code java.net.URI} as having scheme {@code "localhost"}, which is not - * {@code http} or {@code https}. The constructor throws - * {@link IllegalArgumentException} and the factory must return {@code null}. - * - *

Note: {@link AgentProperties} replaces a genuinely empty property value - * with its built-in default, so an empty {@code joular-core-http-url} would - * not reach this path. This test uses a non-empty but scheme-less value - * instead. - */ - @Test - void getPowerSource_noSchemeUrl_returnsNull() throws Exception { - // "localhost:8080" parses as URI with scheme="localhost" (not http/https). - AgentProperties props = propsWithContent( - "power-source-type=http\njoular-core-http-url=localhost:8080\n"); - assertNull(PowerSourceFactory.getPowerSource(props)); - } } diff --git a/src/test/java/org/noureddine/joular/joularcodejava/agent/source/TestLogging.java b/src/test/java/org/noureddine/joular/joularcodejava/agent/source/TestLogging.java new file mode 100644 index 0000000..3fdd166 --- /dev/null +++ b/src/test/java/org/noureddine/joular/joularcodejava/agent/source/TestLogging.java @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2026, Adel Noureddine. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the + * GNU Lesser General Public License v3.0 (LGPL-3.0-only) + * which accompanies this distribution, and is available at + * https://www.gnu.org/licenses/lgpl-3.0.en.html + * + * Author : Adel Noureddine + */ + +package org.noureddine.joular.joularcodejava.agent.source; + +import org.junit.jupiter.api.function.Executable; + +import java.util.logging.Level; +import java.util.logging.Logger; + +final class TestLogging { + + private TestLogging() {} + + static void quietly(Class loggerOwner, Executable body) throws Exception { + Logger logger = Logger.getLogger(loggerOwner.getName()); + Level oldLevel = logger.getLevel(); + logger.setLevel(Level.OFF); + try { + body.execute(); + } catch (Exception | Error e) { + // Failed assertions and anything else worth seeing carry on untouched. + throw e; + } catch (Throwable t) { + throw new AssertionError(t); + } finally { + logger.setLevel(oldLevel); + } + } +} diff --git a/src/test/java/org/noureddine/joular/joularcodejava/agent/utils/AgentPropertiesTest.java b/src/test/java/org/noureddine/joular/joularcodejava/agent/utils/AgentPropertiesTest.java index 3bd1e2e..81b1e20 100644 --- a/src/test/java/org/noureddine/joular/joularcodejava/agent/utils/AgentPropertiesTest.java +++ b/src/test/java/org/noureddine/joular/joularcodejava/agent/utils/AgentPropertiesTest.java @@ -91,21 +91,12 @@ void defaults_powerSourceType_ringbuffer() { } /** - * The default CSV file path is {@code "joularcore-data.csv"}, resolved + * The default CSV file path is {@code "powerjoular-data.csv"}, resolved * relative to the agent's working directory. */ @Test void defaults_csvPath_default() { - assertEquals("joularcore-data.csv", defaults().getJoularCoreCsvPath()); - } - - /** - * The default HTTP endpoint assumes a locally running Joular Core instance - * on the standard port. - */ - @Test - void defaults_httpUrl_default() { - assertEquals("http://localhost:8080/data", defaults().getJoularCoreHttpUrl()); + assertEquals("powerjoular-data.csv", defaults().getPowerJoularCsvPath()); } /** @@ -203,19 +194,7 @@ void fromFile_nonNumericSampleRate_usesDefault() throws Exception { @Test void fromFile_csvPath_customValue() throws Exception { assertEquals("/data/power.csv", - withContent("joular-core-csv-path=/data/power.csv\n").getJoularCoreCsvPath()); - } - - /** - * A fully qualified HTTP URL — including scheme, host, port, and path — - * must be returned verbatim; no parsing or normalisation is applied - * at the properties level. - */ - @Test - void fromFile_httpUrl_customValue() throws Exception { - assertEquals("http://192.168.1.1:9090/power", - withContent("joular-core-http-url=http://192.168.1.1:9090/power\n") - .getJoularCoreHttpUrl()); + withContent("powerjoular-csv-path=/data/power.csv\n").getPowerJoularCsvPath()); } /** @@ -271,7 +250,7 @@ void fromFile_filteringPrefixes_emptyTokensSkipped() throws Exception { * The default ring buffer path is determined at runtime by inspecting * {@code os.name}: *

    - *
  • Windows → {@code "Local\JoularCoreRing"} (named file mapping)
  • + *
  • Windows → {@code "%PROGRAMDATA%\joularcorering"} (a real file, readable by any user)
  • *
  • macOS → {@code "/tmp/joularcorering"}
  • *
  • Linux → {@code "/dev/shm/joularcorering"} (shared-memory filesystem)
  • *
@@ -279,13 +258,13 @@ void fromFile_filteringPrefixes_emptyTokensSkipped() throws Exception { * three CI environments. */ @Test - void getRingBufferPath_default_isOsDependent() { + void getPowerJoularRingBufferPath_default_isOsDependent() { String os = System.getProperty("os.name").toLowerCase(); - String path = defaults().getRingBufferPath(); + String path = defaults().getPowerJoularRingBufferPath(); assertNotNull(path); assertFalse(path.isEmpty()); if (os.contains("win")) { - assertTrue(path.contains("JoularCoreRing"), "Windows path: " + path); + assertTrue(path.endsWith("\\joularcorering"), "Windows path: " + path); } else if (os.contains("mac")) { assertEquals("/tmp/joularcorering", path); } else { @@ -298,8 +277,8 @@ void getRingBufferPath_default_isOsDependent() { * default, allowing the agent to point at a non-standard location. */ @Test - void getRingBufferPath_customValue_returnsCustom() throws Exception { + void getPowerJoularRingBufferPath_customValue_returnsCustom() throws Exception { assertEquals("/custom/ring", - withContent("joular-core-ringbuffer-path=/custom/ring\n").getRingBufferPath()); + withContent("powerjoular-ringbuffer-path=/custom/ring\n").getPowerJoularRingBufferPath()); } } From 0119ed2d547895ae2e7da857852d98c18f7073cf Mon Sep 17 00:00:00 2001 From: Adel Noureddine Date: Tue, 22 Sep 2026 15:29:11 +0200 Subject: [PATCH 2/7] Updates and fixes --- README.md | 20 +- .../joular/joularcodejava/agent/Agent.java | 14 +- .../agent/MonitoringHandler.java | 501 +++++++++++++----- .../agent/result/ResultWriter.java | 14 +- .../source/PowerJoularRingBufferSource.java | 7 + .../agent/source/PowerSource.java | 15 + .../agent/MonitoringHandlerTest.java | 87 +++ .../agent/result/ResultWriterTest.java | 24 +- .../PowerJoularRingBufferSourceTest.java | 25 + 9 files changed, 531 insertions(+), 176 deletions(-) diff --git a/README.md b/README.md index 3245c0f..45df1d7 100644 --- a/README.md +++ b/README.md @@ -174,7 +174,7 @@ Joular Code - Java writes results into the directory configured by `results-path Both files share the same schema: ``` -timestamp,branch,power_watts,energy_joules,interval_seconds +timestamp,branch,power_watts,energy_joules,interval_seconds,coverage ``` | Column | Type | Description | @@ -184,20 +184,32 @@ timestamp,branch,power_watts,energy_joules,interval_seconds | `power_watts` | double | Estimated power consumed by this branch during the cycle (W) | | `energy_joules` | double | Energy = power_watts × interval_seconds (J) | | `interval_seconds` | double | Duration of the monitoring cycle (s), typically ~1.0 | +| `coverage` | double | The share of the JVM's CPU time during the cycle that belonged to threads the agent actually sampled, from 0.0 to 1.0 | + +#### What `coverage` means + +Power is split between threads by the CPU time each one used, and the denominator is every thread that used CPU, not only the ones that were caught in a sample. +Power drawn by a thread the agent never sampled is therefore left unattributed rather than shared out over the threads it did see. +`coverage` says how much of the JVM's CPU time is represented: at `1.0` everything was accounted for, and at `0.6` only 60% of what the JVM consumed are attribued to the observed threads at that timestamp. +Threads that are created and destroyed inside a single cycle are invisible here, because the JVM stops reporting a thread's CPU time once it has ended. ### Example output ``` -timestamp,branch,power_watts,energy_joules,interval_seconds -1746000000000,com.example.Main.main;com.example.Worker.compute,2.341500000,2.341500000,1.000000000 -1746000001000,com.example.Main.main;com.example.Worker.compute,2.158300000,2.158300000,1.000000000 +timestamp,branch,power_watts,energy_joules,interval_seconds,coverage +1746000000000,com.example.Main.main;com.example.Worker.compute,2.341500000,2.341500000,1.000000000,1.0000 +1746000001000,com.example.Main.main;com.example.Worker.compute,2.158300000,2.158300000,1.000000000,0.9974 ``` +The cycle is aligned to PowerJoular when the ring buffer is used: the agent closes a cycle the moment PowerJoular publishes a measurement, so the stack samples and the power describe the same second. With the CSV or RAPL sources the agent keeps its own one second cycle instead. + ## :warning: Troubleshooting - **"No fresh power data ... for 5 cycles"** (`WARNING` log): PowerJoular has stopped writing, or is writing somewhere else. Confirm it is still running, with `-r` for the ring buffer or `-f`/`-o` for CSV. A brief gap is covered by the last value read; past five cycles the agent reports `0.0` and attributes no energy. - **"Could not open the PowerJoular ring buffer ..."** (`WARNING` log): the ring buffer memory area is not there yet. This is not fatal, the agent retries on every cycle, so starting the JVM before PowerJoular is fine. If it never clears, check `powerjoular-ringbuffer-path`. - **"Could not read power data from ..."** (`WARNING` log): the configured `powerjoular-csv-path` does not exist. Check that PowerJoular is running with `-f` or `-o` and writing to the same path. The warning is logged only once until the file appears. +- **"The stack sampler cannot keep up ..."** (`WARNING` log): dumping the stacks of this many threads costs more than `stack-monitoring-sample-rate` allows, so fewer samples were taken than configured. The message gives the rate actually achieved. Raise the sample rate for an honest figure, or accept the coarser data; the energy totals stay correct either way, they are just based on fewer samples. +- **"Only N% of this JVM's CPU time belonged to threads that were sampled"** (`WARNING` log): see the `coverage` column above. The rows are a lower bound rather than wrong. - **"... is the file PowerJoular writes for one monitored process"** (`SEVERE` log): the CSV has three columns, so it holds the power of a single process, which the agent would scale a second time. Point `powerjoular-csv-path` at the file for the whole system instead. - **RAPL mode fails at startup**: `power-source-type=rapl` is Linux-only and requires readable `/sys/class/powercap/intel-rapl/intel-rapl:0/name`, `energy_uj`, and `max_energy_range_uj` files for the `package-0` domain. Depending on the system, this may require elevated permissions. - **No rows in `methods-power-app.csv`**: if `methods-filtering-prefix` is set, the configured prefix may not match any fully-qualified method name in your application. If it is unset, all observed methods are eligible for both output files. diff --git a/src/main/java/org/noureddine/joular/joularcodejava/agent/Agent.java b/src/main/java/org/noureddine/joular/joularcodejava/agent/Agent.java index 25dd13a..fda7b9f 100644 --- a/src/main/java/org/noureddine/joular/joularcodejava/agent/Agent.java +++ b/src/main/java/org/noureddine/joular/joularcodejava/agent/Agent.java @@ -88,17 +88,9 @@ public static void premain(String args, Instrumentation inst) { return; } - // Warm-up OS Bean to avoid initial negative readings - logger.log(Level.INFO, "Warming up OS bean..."); - for (int i = 0; i < 2; i++) { - sunOsBean.getCpuLoad(); - sunOsBean.getProcessCpuLoad(); - try { - Thread.sleep(500); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - } + // The OS bean reports its loads over the interval since the previous call, so the first reading is meaningless and comes back negative. + // It used to be warmed up here with two calls half a second apart, which delayed the application's own main by a full second on every start. + // The monitoring loop's first window does the same job for free: its share comes out as zero, so that window simply attributes nothing. MonitoringHandler monitoringHandler = new MonitoringHandler( properties, diff --git a/src/main/java/org/noureddine/joular/joularcodejava/agent/MonitoringHandler.java b/src/main/java/org/noureddine/joular/joularcodejava/agent/MonitoringHandler.java index c465c1b..e7d18e7 100644 --- a/src/main/java/org/noureddine/joular/joularcodejava/agent/MonitoringHandler.java +++ b/src/main/java/org/noureddine/joular/joularcodejava/agent/MonitoringHandler.java @@ -15,10 +15,11 @@ import java.lang.management.ThreadMXBean; import java.util.HashMap; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; -import java.util.function.Predicate; +import java.util.concurrent.locks.LockSupport; import java.util.logging.Level; import java.util.logging.Logger; import org.noureddine.joular.joularcodejava.agent.result.ResultWriter; @@ -27,8 +28,27 @@ import com.sun.management.OperatingSystemMXBean; /** - * Handles the monitoring loop: sampling at high frequency (10ms) - * and computing branch-level attribution at lower frequency (1s by default). + * Samples the stacks of the running threads and divides the power drawn over a window between the call branches that were seen on them. + * + *

The model

+ *
+ *   threadPower(t) = processPower x cpuDelta(t) / totalCpuDelta
+ *   branchPower(b) = threadPower(t) x samples(t, b) / totalSamples(t)
+ * 
+ * + *

Power is split between threads by the CPU time each one used, and within a thread between branches by how often each was seen on its stack. + * Splitting between threads by CPU time rather than by sample count is what keeps a thread that was blocked in native I/O from being charged for the time it spent waiting: Java reports such a thread as {@code RUNNABLE} even though it is not on a processor, so it is sampled, but its CPU time barely moves. + * + *

The denominator is every thread that used CPU, not only the threads that were sampled. + * Power drawn by a thread never caught in a sample is therefore left unattributed rather than shared out over the threads that were. + * + *

What this cannot see

+ *
    + *
  • Threads that die inside a window. {@code getThreadCpuTime} returns -1 once a thread has gone, so a thread that finishes its work mid window is absent from the closing snapshot and drops out of both the numerator and the denominator. + * Coverage therefore reads optimistically high in an application built on short lived threads.
  • + *
  • Virtual threads. {@code dumpAllThreads} reports platform threads only.
  • + *
  • Where a thread is inside a window. A sample says which branch a thread was on, not whether it was on a processor at that instant, so within one thread the samples taken while it was blocked still dilute the branches that were genuinely running.
  • + *
*/ public class MonitoringHandler implements Runnable { @@ -38,27 +58,55 @@ public class MonitoringHandler implements Runnable { private static final long NANOS_PER_SECOND = 1_000_000_000L; private static final long LOW_SAMPLE_RATE_WARNING_MS = 5L; + /** How long a window runs when the power source publishes no cycle of its own. */ + private static final long DEFAULT_WINDOW_NS = 1000L * NANOS_PER_MILLI; + + /** + * The longest a window may run while waiting for the power source to publish a cycle, so that a producer which has stopped cannot stretch one window out for ever (mainly for ring buffers). + */ + private static final long MAX_WINDOW_NS = 2000L * NANOS_PER_MILLI; + + /** Below this share of the JVM's CPU time being accounted for, the results are worth a warning. */ + private static final double LOW_COVERAGE_THRESHOLD = 0.5; + + /** Report a sampler that cannot keep up once it has missed this share of a window's samples. */ + private static final double HIGH_OVERRUN_THRESHOLD = 0.25; + private volatile boolean running = true; private volatile Thread monitoringThread; private final PowerSource powerSource; private final ResultWriter resultWriter; private final long sampleRateMs; + private final long sampleRateNs; private final List methodsFilteringPrefixes; - private final long computationIntervalMs = 1000; - private final long computationIntervalNs = computationIntervalMs * NANOS_PER_MILLI; + private final boolean filteringMethods; private final ThreadMXBean threadBean; + + /** + * The same bean when it offers the bulk CPU time accessor, which reads every thread in one call instead of one native transition per thread. Null when it does not. + */ + private final com.sun.management.ThreadMXBean bulkThreadBean; + private final OperatingSystemMXBean osBean; private final CountDownLatch startupLatch = new CountDownLatch(1); private volatile boolean startupSuccessful = false; private volatile Throwable startupFailure; + private boolean overrunWarningLogged = false; + private boolean lowCoverageWarningLogged = false; + public MonitoringHandler(AgentProperties properties, PowerSource powerSource, ThreadMXBean threadBean, OperatingSystemMXBean osBean) { this.powerSource = powerSource; this.resultWriter = new ResultWriter(properties.getResultsPath()); this.sampleRateMs = Math.max(1L, properties.getSampleRateMs()); + this.sampleRateNs = this.sampleRateMs * NANOS_PER_MILLI; this.methodsFilteringPrefixes = properties.getMethodsFilteringPrefixes(); + this.filteringMethods = !this.methodsFilteringPrefixes.isEmpty(); this.threadBean = threadBean; + this.bulkThreadBean = threadBean instanceof com.sun.management.ThreadMXBean sunThreadBean + ? sunThreadBean + : null; this.osBean = osBean; if (this.sampleRateMs < LOW_SAMPLE_RATE_WARNING_MS) { logger.log(Level.WARNING, @@ -130,114 +178,13 @@ public void run() { logger.log(Level.SEVERE, "Failed to initialize monitoring loop", e); return; } finally { + // Counted down before the first window, so that the application's own main is not held up while the operating system bean warms itself. startupLatch.countDown(); } while (running) { try { - long windowStartNs = System.nanoTime(); - Map windowStartThreadCpuTime = snapshotThreadCpuTime(monitoringThreadId); - - // 1. Sample stack traces over the computation interval - Map> allStatsSamples = new HashMap<>(); - Map> appStatsSamples = new HashMap<>(); - Map totalThreadSamples = new HashMap<>(); - - while ((System.nanoTime() - windowStartNs) < computationIntervalNs && running) { - // dumpAllThreads avoids the Map wrapping that - // Thread.getAllStackTraces() builds on top of the same native call. - ThreadInfo[] threadInfos = threadBean.dumpAllThreads(false, false); - for (ThreadInfo info : threadInfos) { - if (info == null) { - continue; - } - long threadId = info.getThreadId(); - if (threadId == monitoringThreadId) { - continue; - } - if (info.getThreadState() == Thread.State.RUNNABLE) { - StackTraceElement[] stack = info.getStackTrace(); - - totalThreadSamples.merge(threadId, 1, Integer::sum); - - // Group 1: All branches - String branchAll = getBranchString(stack, s -> true); - if (!branchAll.isEmpty()) { - allStatsSamples.computeIfAbsent(threadId, k -> new HashMap<>()).merge(branchAll, 1, - Integer::sum); - } - - // Group 2: App branches - String branchApp = getBranchString(stack, this::matchesMethodFilter); - if (!branchApp.isEmpty()) { - appStatsSamples.computeIfAbsent(threadId, k -> new HashMap<>()).merge(branchApp, 1, - Integer::sum); - } - } - } - try { - Thread.sleep(sampleRateMs); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - break; - } - } - - long windowEndNs = System.nanoTime(); - long nowMs = System.currentTimeMillis(); - long measuredIntervalNs = windowEndNs - windowStartNs; - double measuredIntervalSeconds = (double) measuredIntervalNs / NANOS_PER_SECOND; - if (measuredIntervalSeconds <= 0) { - continue; - } - // Use the effective attribution window duration for per-interval energy. - // Joular Core power at T is a trailing 1-second average, so this is an - // approximation when the loop duration differs from 1 second. - double energyIntervalSeconds = measuredIntervalSeconds; - - // 2. Calculate thread CPU deltas strictly within the current attribution - // window. - Map intervalThreadsCpuTime = new HashMap<>(); - double totalDeltaCpuTime = 0; - - // Use totalThreadSamples keys to identify active threads - for (long threadId : totalThreadSamples.keySet()) { - long currentCpuTime = threadBean.getThreadCpuTime(threadId); - if (currentCpuTime < 0) { - continue; - } - - long startCpuTime = windowStartThreadCpuTime.getOrDefault(threadId, 0L); - long delta = Math.max(0, currentCpuTime - startCpuTime); - - if (delta > 0) { - intervalThreadsCpuTime.put(threadId, delta); - totalDeltaCpuTime += delta; - } - } - - // 3. Get power and scale to process power - double totalCpuPower = powerSource.getCurrentPower(); - double processShare = estimateProcessShare(); - double totalJHPower = 0.0; - if (totalCpuPower > 0 && processShare > 0) { - totalJHPower = totalCpuPower * processShare; - } - if (!Double.isFinite(totalJHPower) || totalJHPower < 0) { - totalJHPower = 0.0; - } - - Map threadPowerMap = calculateThreadPower( - intervalThreadsCpuTime, - totalDeltaCpuTime, - totalJHPower); - - // 4. Save results - attributeAndSave(allStatsSamples, totalThreadSamples, threadPowerMap, nowMs, energyIntervalSeconds, - "methods-power-all.csv"); - attributeAndSave(appStatsSamples, totalThreadSamples, threadPowerMap, nowMs, energyIntervalSeconds, - "methods-power-app.csv"); - + runOneWindow(monitoringThreadId); } catch (Exception e) { logger.log(Level.SEVERE, "Error during monitoring iteration, continuing.", e); } @@ -255,7 +202,273 @@ public void run() { } } + /** Samples for one monitoring window, then attributes that window's power and writes it out. */ + private void runOneWindow(long monitoringThreadId) { + long windowStartNs = System.nanoTime(); + Map windowStartCpuTime = snapshotThreadCpuTime(monitoringThreadId); + + SampleSet samples = new SampleSet(); + long openingCycle = powerSource.cycleCounter(); + + // An absolute schedule: each sample is due at a fixed offset from the start of the window, so the time the stack dump takes is absorbed into the interval rather than added on top of it. + // Sleeping for the sample rate after the dump instead, quietly turns a configured 10 ms into 10 ms plus the dump, halving the resolution on a JVM with many threads + long nextSampleNs = windowStartNs; + int missedSamples = 0; + boolean windowRanItsCourse = false; + + while (running && !Thread.currentThread().isInterrupted()) { + long now = System.nanoTime(); + if (windowIsComplete(now - windowStartNs, openingCycle)) { + windowRanItsCourse = true; + break; + } + + takeSample(samples, monitoringThreadId); + + nextSampleNs += sampleRateNs; + long parkNs = nextSampleNs - System.nanoTime(); + if (parkNs > 0) { + // parkNanos neither throws on interrupt nor clears the flag, so the loop condition above is what ends the window when stop() interrupts us + LockSupport.parkNanos(parkNs); + } else { + // The dump alone already overran the interval, so there is nothing left to wait for + missedSamples++; + nextSampleNs = System.nanoTime(); + } + } + + if (!windowRanItsCourse) { + // The agent is shutting down and this window was cut off part way through. + // Its handful of samples cover a fraction of a second of a JVM that is already winding down, so attributing them would put a row in the results that looks like a cycle and is not: + return; + } + + long windowEndNs = System.nanoTime(); + long nowMs = System.currentTimeMillis(); + double intervalSeconds = (double) (windowEndNs - windowStartNs) / NANOS_PER_SECOND; + if (intervalSeconds <= 0) { + return; + } + + reportSampling(missedSamples, samples.samplesTaken, intervalSeconds); + + Map windowEndCpuTime = snapshotThreadCpuTime(monitoringThreadId); + Attribution attribution = attribute(samples.totalPerThread, windowStartCpuTime, windowEndCpuTime); + reportCoverage(attribution.coverage()); + + double processPower = processPower(); + Map threadPower = calculateThreadPower( + attribution.sampledCpuDeltas(), attribution.totalCpuDeltaNs(), processPower); + + attributeAndSave(samples.allBranches, samples.totalPerThread, threadPower, + nowMs, intervalSeconds, attribution.coverage(), "methods-power-all.csv"); + attributeAndSave(samples.appBranches, samples.totalPerThread, threadPower, + nowMs, intervalSeconds, attribution.coverage(), "methods-power-app.csv"); + } + + /** + * Whether the window has completed. + * + *

When the power source publishes a cycle counter, the window ends the moment that counter moves, so that the agent's window and the producer's measurement (ring buffer mainly) cover the same stretch of time. + * The producer's value at T is the average over the second up to T, and closing the window on that same edge is what makes the samples describe that same second. + * Without a counter the window is simply a second long, which leaves the two free to drift apart by up to a second. + */ + private boolean windowIsComplete(long elapsedNs, long openingCycle) { + if (openingCycle < 0) { + return elapsedNs >= DEFAULT_WINDOW_NS; + } + // The cap keeps a producer that has stopped from stretching one window out forever + return powerSource.cycleCounter() != openingCycle || elapsedNs >= MAX_WINDOW_NS; + } + + /** One pass over every live thread, recording the branch each runnable one is on */ + private void takeSample(SampleSet samples, long monitoringThreadId) { + // dumpAllThreads avoids the Map wrapping that Thread.getAllStackTraces() builds on top of the same native call + ThreadInfo[] threadInfos = threadBean.dumpAllThreads(false, false); + samples.samplesTaken++; + + for (ThreadInfo info : threadInfos) { + if (info == null) { + continue; + } + long threadId = info.getThreadId(); + if (threadId == monitoringThreadId || info.getThreadState() != Thread.State.RUNNABLE) { + continue; + } + + StackTraceElement[] stack = info.getStackTrace(); + if (stack.length == 0) { + continue; + } + + samples.totalPerThread.merge(threadId, 1, Integer::sum); + + if (filteringMethods) { + StringBuilder app = new StringBuilder(estimatedBranchLength(stack)); + String all = buildBranch(stack, app); + if (!all.isEmpty()) { + samples.recordAll(threadId, all); + } + if (app.length() > 0) { + samples.recordApp(threadId, app.toString()); + } + } else { + // With no filter the two groupings hold the same branches, so the stack is walked once and the one string is shared between them. + String branch = buildBranch(stack); + if (!branch.isEmpty()) { + samples.recordAll(threadId, branch); + samples.recordApp(threadId, branch); + } + } + } + } + + /** + * Builds the branch, oldest frame first, as the viewer expects. + * + *

The class and the method go straight into the builder rather than through a joined {@code "class.method"} string. + * Only the filter needs that string, and this is the path taken when there is no filter: one throwaway string per frame, per thread, per sample adds up to everal hundred thousand allocations a second on a JVM with many threads. + */ + private static String buildBranch(StackTraceElement[] stack) { + // Presized: the default 16 characters would grow to a couple of kilobytes through about eight array copies for a stack of any depth + StringBuilder branch = new StringBuilder(estimatedBranchLength(stack)); + for (int i = stack.length - 1; i >= 0; i--) { + if (branch.length() > 0) { + branch.append(';'); + } + StackTraceElement element = stack[i]; + branch.append(element.getClassName()).append('.').append(element.getMethodName()); + } + return branch.toString(); + } + + /** + * Builds the full branch and, in the same walk of the stack, the branch of just those frames passing the method filter. + * + *

The filter matches on the whole {@code "class.method"} name, so here that string has to be built. It is then reused for both branches rather than formed twice. + */ + private String buildBranch(StackTraceElement[] stack, StringBuilder filtered) { + StringBuilder all = new StringBuilder(estimatedBranchLength(stack)); + for (int i = stack.length - 1; i >= 0; i--) { + StackTraceElement element = stack[i]; + String methodName = element.getClassName() + "." + element.getMethodName(); + append(all, methodName); + if (matchesMethodFilter(methodName)) { + append(filtered, methodName); + } + } + return all.toString(); + } + + private static int estimatedBranchLength(StackTraceElement[] stack) { + return Math.max(64, stack.length * 48); + } + + private static void append(StringBuilder branch, String methodName) { + if (branch.length() > 0) { + branch.append(';'); + } + branch.append(methodName); + } + + /** What was seen during one monitoring window. */ + private static final class SampleSet { + final Map> allBranches = new HashMap<>(); + final Map> appBranches = new HashMap<>(); + final Map totalPerThread = new HashMap<>(); + int samplesTaken = 0; + + void recordAll(long threadId, String branch) { + record(allBranches, threadId, branch); + } + + void recordApp(long threadId, String branch) { + record(appBranches, threadId, branch); + } + + private static void record(Map> branches, long threadId, String branch) { + branches.computeIfAbsent(threadId, k -> new HashMap<>()).merge(branch, 1, Integer::sum); + } + } + + /** How a monitoring window's CPU time divided between the threads that were sampled and the rest */ + record Attribution(Map sampledCpuDeltas, long totalCpuDeltaNs, double coverage) { + } + + /** + * Works out how much CPU each thread used over the monitoring window, and how much of that belonged to threads that were actually sampled. + */ + static Attribution attribute(Map sampledThreads, Map startCpuTime, + Map endCpuTime) { + Map sampledDeltas = new HashMap<>(); + long totalDeltaNs = 0; + long sampledDeltaNs = 0; + + for (Map.Entry entry : endCpuTime.entrySet()) { + long threadId = entry.getKey(); + // A thread missing from the opening snapshot was started after the window began, so every nanosecond on its counter was spent inside the window and all of it counts. + // Safe because a thread id is never reused. + long delta = entry.getValue() - startCpuTime.getOrDefault(threadId, 0L); + if (delta <= 0) { + continue; + } + totalDeltaNs += delta; + if (sampledThreads.containsKey(threadId)) { + sampledDeltaNs += delta; + sampledDeltas.put(threadId, delta); + } + } + + double coverage = totalDeltaNs > 0 ? (double) sampledDeltaNs / totalDeltaNs : 0.0; + return new Attribution(sampledDeltas, totalDeltaNs, coverage); + } + + private void reportSampling(int missedSamples, int samplesTaken, double intervalSeconds) { + logger.log(Level.FINE, () -> "Window: " + samplesTaken + " samples over " + + String.format(Locale.ROOT, "%.3f", intervalSeconds) + " s, " + + missedSamples + " overran the interval"); + + if (overrunWarningLogged || samplesTaken <= 0) { + return; + } + if ((double) missedSamples / samplesTaken < HIGH_OVERRUN_THRESHOLD) { + return; + } + + long achievedMs = Math.round(intervalSeconds * 1000.0 / samplesTaken); + logger.log(Level.WARNING, + () -> "The stack sampler cannot keep up with stack-monitoring-sample-rate=" + + sampleRateMs + " ms: " + samplesTaken + " samples were taken in the last" + + " window, an effective rate of about " + achievedMs + " ms. Dumping the" + + " stacks of this many threads costs more than the interval allows. Raise" + + " the sample rate for an honest one, or accept the coarser data."); + overrunWarningLogged = true; + } + + private void reportCoverage(double coverage) { + logger.log(Level.FINE, () -> "Window coverage: " + coverage); + if (lowCoverageWarningLogged || coverage <= 0 || coverage >= LOW_COVERAGE_THRESHOLD) { + return; + } + + logger.log(Level.WARNING, + () -> "Only " + Math.round(coverage * 100) + "% of this JVM's CPU time belonged to" + + " threads that were sampled, so the power reported per method is a lower bound. Threads that are short lived, or virtual threads, which the JVM does not report here, are the usual causes. Another cause is a sample rate too coarse for the number of threads."); + lowCoverageWarningLogged = true; + } + + /** The power this JVM is drawing: the machine's CPU power times this JVM's share of the work. */ + private double processPower() { + double totalCpuPower = powerSource.getCurrentPower(); + double processShare = estimateProcessShare(); + double power = totalCpuPower > 0 && processShare > 0 ? totalCpuPower * processShare : 0.0; + return Double.isFinite(power) && power > 0 ? power : 0.0; + } + + /** The share of the machine's busy CPU time that belongs to this JVM */ private double estimateProcessShare() { + // The bean's first reading is negative, which sanitizeCpuLoad turns into zero, so the first window attributes nothing and warms the bean for the ones after it. + // Agent.premain used to do that warming itself and held up the application's own main for a second doing it double processCpuLoad = sanitizeCpuLoad(osBean.getProcessCpuLoad()); double systemCpuLoad = sanitizeCpuLoad(osBean.getCpuLoad()); if (processCpuLoad <= 0) { @@ -271,9 +484,6 @@ private double estimateProcessShare() { } private boolean matchesMethodFilter(String methodName) { - if (methodsFilteringPrefixes.isEmpty()) { - return true; - } for (String prefix : methodsFilteringPrefixes) { if (methodName.startsWith(prefix)) { return true; @@ -289,58 +499,59 @@ private double sanitizeCpuLoad(double value) { return Math.min(1.0, value); } - private String getBranchString(StackTraceElement[] stackTrace, Predicate filter) { - StringBuilder sb = new StringBuilder(); - boolean first = true; - for (int i = stackTrace.length - 1; i >= 0; i--) { - StackTraceElement element = stackTrace[i]; - String methodName = element.getClassName() + "." + element.getMethodName(); - if (!filter.test(methodName)) { - continue; - } - if (!first) { - sb.append(";"); - } - sb.append(methodName); - first = false; - } - return sb.toString(); - } - + /** + * Every live thread's cumulative CPU time, the agent's own thread aside since its time is overhead rather than application work. + */ private Map snapshotThreadCpuTime(long excludedThreadId) { - Map snapshot = new HashMap<>(); - for (long threadId : threadBean.getAllThreadIds()) { - if (threadId == excludedThreadId) { - continue; + long[] ids = threadBean.getAllThreadIds(); + Map snapshot = new HashMap<>(Math.max(16, ids.length * 2)); + + if (bulkThreadBean != null) { + // One call for every thread, rather than one native transition per thread + long[] times = bulkThreadBean.getThreadCpuTime(ids); + for (int i = 0; i < ids.length; i++) { + if (ids[i] != excludedThreadId && times[i] >= 0) { + snapshot.put(ids[i], times[i]); + } } - long cpuTime = threadBean.getThreadCpuTime(threadId); - if (cpuTime >= 0) { - snapshot.put(threadId, cpuTime); + } else { + for (long id : ids) { + if (id == excludedThreadId) { + continue; + } + long cpuTime = threadBean.getThreadCpuTime(id); + if (cpuTime >= 0) { + snapshot.put(id, cpuTime); + } } } return snapshot; } - static Map calculateThreadPower(Map intervalThreadsCpuTime, double totalDeltaCpuTime, + /** + * Divides the JVM's power between the sampled threads. + * + *

{@code totalDeltaCpuTime} covers every thread that used CPU, including those never caught in a sample, so their share is simply not handed out rather than being spread over the threads that were seen. + */ + static Map calculateThreadPower(Map sampledCpuDeltas, double totalDeltaCpuTime, double totalJHPower) { Map threadPowerMap = new HashMap<>(); if (totalJHPower <= 0 || !Double.isFinite(totalJHPower) || totalDeltaCpuTime <= 0) { return threadPowerMap; } - for (Map.Entry entry : intervalThreadsCpuTime.entrySet()) { - long threadId = entry.getKey(); + for (Map.Entry entry : sampledCpuDeltas.entrySet()) { long threadCpuTime = entry.getValue(); if (threadCpuTime > 0) { - double threadPower = totalJHPower * ((double) threadCpuTime / totalDeltaCpuTime); - threadPowerMap.put(threadId, threadPower); + threadPowerMap.put(entry.getKey(), totalJHPower * (threadCpuTime / totalDeltaCpuTime)); } } return threadPowerMap; } private void attributeAndSave(Map> stats, Map totalThreadSamples, - Map threadPowerMap, long timestampMs, double intervalSeconds, String fileName) { + Map threadPowerMap, long timestampMs, double intervalSeconds, double coverage, + String fileName) { Map methodPowerTotals = new HashMap<>(); for (Map.Entry> threadEntry : stats.entrySet()) { @@ -353,16 +564,14 @@ private void attributeAndSave(Map> stats, Map methodCounts = threadEntry.getValue(); - - for (var methodEntry : methodCounts.entrySet()) { + for (var methodEntry : threadEntry.getValue().entrySet()) { double methodPower = (methodEntry.getValue().doubleValue() / totalSamples) * threadPower; methodPowerTotals.merge(methodEntry.getKey(), methodPower, Double::sum); } } if (!methodPowerTotals.isEmpty()) { - resultWriter.writeRuntimeMethods(methodPowerTotals, timestampMs, intervalSeconds, fileName); + resultWriter.writeRuntimeMethods(methodPowerTotals, timestampMs, intervalSeconds, coverage, fileName); } } } diff --git a/src/main/java/org/noureddine/joular/joularcodejava/agent/result/ResultWriter.java b/src/main/java/org/noureddine/joular/joularcodejava/agent/result/ResultWriter.java index b912901..0f0b1ba 100644 --- a/src/main/java/org/noureddine/joular/joularcodejava/agent/result/ResultWriter.java +++ b/src/main/java/org/noureddine/joular/joularcodejava/agent/result/ResultWriter.java @@ -26,7 +26,8 @@ public class ResultWriter { private static final Logger logger = Logger.getLogger(ResultWriter.class.getName()); - private static final String CSV_HEADER = "timestamp,branch,power_watts,energy_joules,interval_seconds"; + private static final String CSV_HEADER = + "timestamp,branch,power_watts,energy_joules,interval_seconds,coverage"; private final Path resultsDir; private final Map openWriters = new HashMap<>(); @@ -61,10 +62,16 @@ public void verifyWritable() throws IOException { } } + /** + * Writes one row per branch for a monitoring cycle. + * + * @param coverage the share of this JVM's CPU time that belonged to threads the agent actually sampled during the cycle. Below 1.0 the figures on these rows are a lower bound: the power of threads that were never sampled is left unattributed rather than shared out over the ones that were. + */ public void writeRuntimeMethods( Map methodPower, long timestamp, double intervalSeconds, + double coverage, String fileName) { Path path = resultsDir.resolve(fileName); logger.log(Level.FINE, () -> "Writing " + methodPower.size() + " methods to " + path); @@ -88,12 +95,13 @@ public void writeRuntimeMethods( double energyJoules = powerWatts * intervalSeconds; writer.write(String.format( Locale.US, - "%d,%s,%.9f,%.9f,%.9f", + "%d,%s,%.9f,%.9f,%.9f,%.4f", timestamp, csvEscape(entry.getKey()), powerWatts, energyJoules, - intervalSeconds)); + intervalSeconds, + coverage)); writer.newLine(); } } diff --git a/src/main/java/org/noureddine/joular/joularcodejava/agent/source/PowerJoularRingBufferSource.java b/src/main/java/org/noureddine/joular/joularcodejava/agent/source/PowerJoularRingBufferSource.java index 069de47..9bc7df1 100644 --- a/src/main/java/org/noureddine/joular/joularcodejava/agent/source/PowerJoularRingBufferSource.java +++ b/src/main/java/org/noureddine/joular/joularcodejava/agent/source/PowerJoularRingBufferSource.java @@ -110,6 +110,13 @@ public double getCurrentPower() { return 0.0; } + @Override + public long cycleCounter() { + // PowerJoular raises the counter once it has written a cycle, so a change here is exactly the edge the agent wants to close its window on. + ByteBuffer area = this.buffer; + return area == null ? -1 : area.getLong(0); + } + /** * Reads cpu_power from the newest complete entry. * diff --git a/src/main/java/org/noureddine/joular/joularcodejava/agent/source/PowerSource.java b/src/main/java/org/noureddine/joular/joularcodejava/agent/source/PowerSource.java index 4c73fb7..b54498d 100644 --- a/src/main/java/org/noureddine/joular/joularcodejava/agent/source/PowerSource.java +++ b/src/main/java/org/noureddine/joular/joularcodejava/agent/source/PowerSource.java @@ -51,6 +51,21 @@ public interface PowerSource { */ double getCurrentPower(); + /** + * Counter incremented by the producer on each new measurement, or -1 if it has no such counter. + * + *

The agent ends its attribution window when this value changes, so the window covers the same time span as the producer's measurement. + * PowerJoular reports the average power over the second ending at T, so the stack samples in that window belong to the same second as the power they are charged with. + * If this returns -1, the agent uses its own one second timer instead, which can drift relative to the producer. + * + *

Called on every sample tick, so it must be cheap and must not block. + * + * @return the producer's cycle counter, or -1 when unknown + */ + default long cycleCounter() { + return -1; + } + /** * Close the power source connection. Must be safe to call even if {@link #initialize()} * was never called or threw. diff --git a/src/test/java/org/noureddine/joular/joularcodejava/agent/MonitoringHandlerTest.java b/src/test/java/org/noureddine/joular/joularcodejava/agent/MonitoringHandlerTest.java index 81c761f..98ef84c 100644 --- a/src/test/java/org/noureddine/joular/joularcodejava/agent/MonitoringHandlerTest.java +++ b/src/test/java/org/noureddine/joular/joularcodejava/agent/MonitoringHandlerTest.java @@ -353,4 +353,91 @@ void calculateThreadPower_withoutCpuDeltas_returnsEmptyMap() { assertTrue(power.isEmpty()); } + + // ------------------------------------------------------------------------- + // attribute: which CPU time goes in the denominator, and what coverage says + // ------------------------------------------------------------------------- + + /** + * A thread that used CPU without ever being caught in a sample still belongs in the denominator. + * Leaving it out handed the whole of the JVM's power to the threads that were seen and so overstated every method on them. + */ + @Test + void attribute_unsampledThreadStaysInTheDenominator() { + Map sampled = Map.of(1L, 10); + Map start = Map.of(1L, 0L, 2L, 0L); + Map end = Map.of(1L, 300L, 2L, 700L); + + MonitoringHandler.Attribution attribution = MonitoringHandler.attribute(sampled, start, end); + + assertEquals(1000L, attribution.totalCpuDeltaNs(), "both threads' CPU time counts"); + assertEquals(Map.of(1L, 300L), attribution.sampledCpuDeltas(), "only the sampled one is paid"); + assertEquals(0.3, attribution.coverage(), 1e-9); + } + + /** With every CPU-using thread sampled, nothing is missing. */ + @Test + void attribute_everyThreadSampled_coverageIsOne() { + MonitoringHandler.Attribution attribution = MonitoringHandler.attribute( + Map.of(1L, 5, 2L, 5), Map.of(1L, 0L, 2L, 0L), Map.of(1L, 400L, 2L, 600L)); + + assertEquals(1.0, attribution.coverage(), 1e-9); + assertEquals(1000L, attribution.totalCpuDeltaNs()); + } + + /** + * A thread absent from the opening snapshot was started inside the window, so the whole of its + * counter was spent there. Thread ids are never reused, so this cannot pick up a predecessor's + * time. + */ + @Test + void attribute_threadStartedMidWindow_countsAllOfItsCpuTime() { + MonitoringHandler.Attribution attribution = MonitoringHandler.attribute( + Map.of(7L, 3), Map.of(), Map.of(7L, 500L)); + + assertEquals(500L, attribution.totalCpuDeltaNs()); + assertEquals(Map.of(7L, 500L), attribution.sampledCpuDeltas()); + assertEquals(1.0, attribution.coverage(), 1e-9); + } + + /** A thread whose counter did not move contributes nothing either way. */ + @Test + void attribute_idleThreadIgnored() { + MonitoringHandler.Attribution attribution = MonitoringHandler.attribute( + Map.of(1L, 4), Map.of(1L, 100L, 2L, 900L), Map.of(1L, 600L, 2L, 900L)); + + assertEquals(500L, attribution.totalCpuDeltaNs()); + assertEquals(1.0, attribution.coverage(), 1e-9, "the idle thread is not missing coverage"); + } + + /** No CPU used at all is not a division by zero. */ + @Test + void attribute_noCpuUsed_coverageIsZero() { + MonitoringHandler.Attribution attribution = MonitoringHandler.attribute( + Map.of(1L, 4), Map.of(1L, 100L), Map.of(1L, 100L)); + + assertEquals(0L, attribution.totalCpuDeltaNs()); + assertEquals(0.0, attribution.coverage(), 1e-9); + assertTrue(attribution.sampledCpuDeltas().isEmpty()); + } + + /** + * The point of the whole arrangement: what is handed out adds up to less than the JVM drew, by + * exactly the share that was never observed. The alternative, normalising over the sampled + * threads, would make the totals balance by inflating the methods that happened to be seen. + */ + @Test + void calculateThreadPower_unobservedShareIsLeftUnattributed() { + MonitoringHandler.Attribution attribution = MonitoringHandler.attribute( + Map.of(1L, 10), Map.of(1L, 0L, 2L, 0L), Map.of(1L, 250L, 2L, 750L)); + + Map power = MonitoringHandler.calculateThreadPower( + attribution.sampledCpuDeltas(), attribution.totalCpuDeltaNs(), 80.0); + + assertEquals(20.0, power.get(1L), 1e-9, "a quarter of the CPU time earns a quarter of the power"); + double handedOut = power.values().stream().mapToDouble(Double::doubleValue).sum(); + assertTrue(handedOut < 80.0, "the unsampled thread's 60 W is not shared out"); + assertEquals(attribution.coverage() * 80.0, handedOut, 1e-9, + "what is attributed is exactly the covered share"); + } } diff --git a/src/test/java/org/noureddine/joular/joularcodejava/agent/result/ResultWriterTest.java b/src/test/java/org/noureddine/joular/joularcodejava/agent/result/ResultWriterTest.java index 9789cec..b8addfe 100644 --- a/src/test/java/org/noureddine/joular/joularcodejava/agent/result/ResultWriterTest.java +++ b/src/test/java/org/noureddine/joular/joularcodejava/agent/result/ResultWriterTest.java @@ -179,11 +179,11 @@ void verifyWritable_probeFileDeletedAfterCheck() throws Exception { void writeRuntimeMethods_newFile_writesHeaderThenData() throws Exception { ResultWriter writer = new ResultWriter(tempDir.toString()); writer.verifyWritable(); - writer.writeRuntimeMethods(Map.of("com.A.method", 5.0), 1000L, 1.0, "out.csv"); + writer.writeRuntimeMethods(Map.of("com.A.method", 5.0), 1000L, 1.0, 1.0, "out.csv"); writer.close(); List lines = Files.readAllLines(tempDir.resolve("out.csv"), StandardCharsets.UTF_8); - assertEquals("timestamp,branch,power_watts,energy_joules,interval_seconds", lines.get(0)); + assertEquals("timestamp,branch,power_watts,energy_joules,interval_seconds,coverage", lines.get(0)); assertEquals(2, lines.size()); } @@ -202,7 +202,7 @@ void writeRuntimeMethods_zeroPower_rowSkipped() throws Exception { Map map = new LinkedHashMap<>(); map.put("A.zero", 0.0); map.put("B.positive", 3.0); - writer.writeRuntimeMethods(map, 1000L, 1.0, "out.csv"); + writer.writeRuntimeMethods(map, 1000L, 1.0, 1.0, "out.csv"); writer.close(); List lines = Files.readAllLines(tempDir.resolve("out.csv"), StandardCharsets.UTF_8); @@ -222,7 +222,7 @@ void writeRuntimeMethods_negativePower_rowSkipped() throws Exception { Map map = new LinkedHashMap<>(); map.put("A.neg", -1.0); map.put("B.pos", 2.0); - writer.writeRuntimeMethods(map, 1000L, 1.0, "out.csv"); + writer.writeRuntimeMethods(map, 1000L, 1.0, 1.0, "out.csv"); writer.close(); List lines = Files.readAllLines(tempDir.resolve("out.csv"), StandardCharsets.UTF_8); @@ -240,7 +240,7 @@ void writeRuntimeMethods_negativePower_rowSkipped() throws Exception { void writeRuntimeMethods_energyEqualsPoworTimesInterval() throws Exception { ResultWriter writer = new ResultWriter(tempDir.toString()); writer.verifyWritable(); - writer.writeRuntimeMethods(Map.of("m.method", 4.0), 1000L, 2.5, "out.csv"); + writer.writeRuntimeMethods(Map.of("m.method", 4.0), 1000L, 2.5, 1.0, "out.csv"); writer.close(); List lines = Files.readAllLines(tempDir.resolve("out.csv"), StandardCharsets.UTF_8); @@ -265,7 +265,7 @@ void writeRuntimeMethods_usesNineDecimalPrecision() throws Exception { ResultWriter writer = new ResultWriter(tempDir.toString()); writer.verifyWritable(); // 1/3 is an irrational decimal — ensures the formatter actually rounds at 9 places. - writer.writeRuntimeMethods(Map.of("m.m", 1.0 / 3.0), 1000L, 1.0, "out.csv"); + writer.writeRuntimeMethods(Map.of("m.m", 1.0 / 3.0), 1000L, 1.0, 1.0, "out.csv"); writer.close(); List lines = Files.readAllLines(tempDir.resolve("out.csv"), StandardCharsets.UTF_8); @@ -284,8 +284,8 @@ void writeRuntimeMethods_usesNineDecimalPrecision() throws Exception { void writeRuntimeMethods_appendsWithoutDuplicateHeader() throws Exception { ResultWriter writer = new ResultWriter(tempDir.toString()); writer.verifyWritable(); - writer.writeRuntimeMethods(Map.of("A.m", 1.0), 1000L, 1.0, "out.csv"); - writer.writeRuntimeMethods(Map.of("B.m", 2.0), 2000L, 1.0, "out.csv"); + writer.writeRuntimeMethods(Map.of("A.m", 1.0), 1000L, 1.0, 1.0, "out.csv"); + writer.writeRuntimeMethods(Map.of("B.m", 2.0), 2000L, 1.0, 1.0, "out.csv"); writer.close(); List lines = Files.readAllLines(tempDir.resolve("out.csv"), StandardCharsets.UTF_8); @@ -310,11 +310,11 @@ void writeRuntimeMethods_addsHeaderToEmptyPreexistingFile() throws Exception { ResultWriter writer = new ResultWriter(tempDir.toString()); writer.verifyWritable(); - writer.writeRuntimeMethods(Map.of("X.m", 5.0), 1000L, 1.0, "out.csv"); + writer.writeRuntimeMethods(Map.of("X.m", 5.0), 1000L, 1.0, 1.0, "out.csv"); writer.close(); List lines = Files.readAllLines(outFile, StandardCharsets.UTF_8); - assertEquals("timestamp,branch,power_watts,energy_joules,interval_seconds", lines.get(0)); + assertEquals("timestamp,branch,power_watts,energy_joules,interval_seconds,coverage", lines.get(0)); } /** @@ -327,7 +327,7 @@ void writeRuntimeMethods_addsHeaderToEmptyPreexistingFile() throws Exception { void writeRuntimeMethods_csvEscapeAppliedToKey() throws Exception { ResultWriter writer = new ResultWriter(tempDir.toString()); writer.verifyWritable(); - writer.writeRuntimeMethods(Map.of("foo,bar", 3.0), 1000L, 1.0, "out.csv"); + writer.writeRuntimeMethods(Map.of("foo,bar", 3.0), 1000L, 1.0, 1.0, "out.csv"); writer.close(); String content = Files.readString(tempDir.resolve("out.csv"), StandardCharsets.UTF_8); @@ -343,7 +343,7 @@ void writeRuntimeMethods_csvEscapeAppliedToKey() throws Exception { void close_isIdempotent() throws Exception { ResultWriter writer = new ResultWriter(tempDir.toString()); writer.verifyWritable(); - writer.writeRuntimeMethods(Map.of("A.m", 1.0), 1000L, 1.0, "out.csv"); + writer.writeRuntimeMethods(Map.of("A.m", 1.0), 1000L, 1.0, 1.0, "out.csv"); assertDoesNotThrow(() -> { writer.close(); writer.close(); diff --git a/src/test/java/org/noureddine/joular/joularcodejava/agent/source/PowerJoularRingBufferSourceTest.java b/src/test/java/org/noureddine/joular/joularcodejava/agent/source/PowerJoularRingBufferSourceTest.java index ea6c805..f3c4290 100644 --- a/src/test/java/org/noureddine/joular/joularcodejava/agent/source/PowerJoularRingBufferSourceTest.java +++ b/src/test/java/org/noureddine/joular/joularcodejava/agent/source/PowerJoularRingBufferSourceTest.java @@ -260,4 +260,29 @@ void close_withoutInitialize_isSafe() { PowerJoularRingBufferSource source = new PowerJoularRingBufferSource("/nonexistent"); assertDoesNotThrow(source::close); } + + // ------------------------------------------------------------------------- + // cycleCounter: the edge the agent closes its attribution window on + // ------------------------------------------------------------------------- + + /** Unattached, the source has no cycle to offer and the agent falls back to its own timer. */ + @Test + void cycleCounter_notAttached_returnsMinusOne() throws Exception { + PowerJoularRingBufferSource source = new PowerJoularRingBufferSource( + tempDir.resolve("nonexistent").toString()); + quietly(PowerJoularRingBufferSource.class, () -> { + source.initialize(); + assertEquals(-1, source.cycleCounter()); + }); + } + + /** Attached, it reports PowerJoular's counter, and follows it as cycles are written. */ + @Test + void cycleCounter_attached_followsTheProducer() throws Exception { + PowerJoularRingBufferSource source = sourceFor(writeTempRingBuffer(4L, 3, 12.0)); + assertEquals(4L, source.cycleCounter()); + + writeTempRingBuffer(5L, 4, 13.0); + assertEquals(5L, source.cycleCounter(), "a new cycle must be visible to the agent"); + } } From 91b661c917242df84b70526ae50fcecc23c0e84e Mon Sep 17 00:00:00 2001 From: Adel Noureddine Date: Tue, 22 Sep 2026 15:44:12 +0200 Subject: [PATCH 3/7] minor fixes --- README.md | 2 +- .../joular/joularcodejava/agent/Agent.java | 10 +- .../agent/MonitoringHandler.java | 100 ++++++++---------- .../agent/MonitoringHandlerTest.java | 10 +- 4 files changed, 57 insertions(+), 65 deletions(-) diff --git a/README.md b/README.md index 45df1d7..fd02057 100644 --- a/README.md +++ b/README.md @@ -190,7 +190,7 @@ timestamp,branch,power_watts,energy_joules,interval_seconds,coverage Power is split between threads by the CPU time each one used, and the denominator is every thread that used CPU, not only the ones that were caught in a sample. Power drawn by a thread the agent never sampled is therefore left unattributed rather than shared out over the threads it did see. -`coverage` says how much of the JVM's CPU time is represented: at `1.0` everything was accounted for, and at `0.6` only 60% of what the JVM consumed are attribued to the observed threads at that timestamp. +`coverage` says how much of the JVM's CPU time is represented: at `1.0` everything was accounted for, and at `0.6` only 60% of what the JVM consumed are attributed to the observed threads at that timestamp. Threads that are created and destroyed inside a single cycle are invisible here, because the JVM stops reporting a thread's CPU time once it has ended. ### Example output diff --git a/src/main/java/org/noureddine/joular/joularcodejava/agent/Agent.java b/src/main/java/org/noureddine/joular/joularcodejava/agent/Agent.java index fda7b9f..cf0396c 100644 --- a/src/main/java/org/noureddine/joular/joularcodejava/agent/Agent.java +++ b/src/main/java/org/noureddine/joular/joularcodejava/agent/Agent.java @@ -14,7 +14,6 @@ import java.lang.instrument.Instrumentation; import java.lang.management.ManagementFactory; import java.lang.management.OperatingSystemMXBean; -import java.lang.management.ThreadMXBean; import java.io.PrintWriter; import java.io.StringWriter; import java.util.Locale; @@ -70,7 +69,11 @@ public static void premain(String args, Instrumentation inst) { return; } - ThreadMXBean threadBean = ManagementFactory.getThreadMXBean(); + if (!(ManagementFactory.getThreadMXBean() instanceof com.sun.management.ThreadMXBean threadBean)) { + logger.log(Level.SEVERE, + "Unsupported JVM: requires com.sun.management.ThreadMXBean to read every thread's CPU time in one call. Joular Code - Java will not start."); + return; + } if (threadBean.isThreadCpuTimeSupported()) { if (!threadBean.isThreadCpuTimeEnabled()) { threadBean.setThreadCpuTimeEnabled(true); @@ -89,8 +92,7 @@ public static void premain(String args, Instrumentation inst) { } // The OS bean reports its loads over the interval since the previous call, so the first reading is meaningless and comes back negative. - // It used to be warmed up here with two calls half a second apart, which delayed the application's own main by a full second on every start. - // The monitoring loop's first window does the same job for free: its share comes out as zero, so that window simply attributes nothing. + // The monitoring loop's first window absorbs that: its share comes out as zero, so that window simply attributes nothing. MonitoringHandler monitoringHandler = new MonitoringHandler( properties, diff --git a/src/main/java/org/noureddine/joular/joularcodejava/agent/MonitoringHandler.java b/src/main/java/org/noureddine/joular/joularcodejava/agent/MonitoringHandler.java index e7d18e7..c8850b8 100644 --- a/src/main/java/org/noureddine/joular/joularcodejava/agent/MonitoringHandler.java +++ b/src/main/java/org/noureddine/joular/joularcodejava/agent/MonitoringHandler.java @@ -12,7 +12,6 @@ package org.noureddine.joular.joularcodejava.agent; import java.lang.management.ThreadInfo; -import java.lang.management.ThreadMXBean; import java.util.HashMap; import java.util.List; import java.util.Locale; @@ -26,6 +25,7 @@ import org.noureddine.joular.joularcodejava.agent.source.PowerSource; import org.noureddine.joular.joularcodejava.agent.utils.AgentProperties; import com.sun.management.OperatingSystemMXBean; +import com.sun.management.ThreadMXBean; /** * Samples the stacks of the running threads and divides the power drawn over a window between the call branches that were seen on them. @@ -77,16 +77,8 @@ public class MonitoringHandler implements Runnable { private final PowerSource powerSource; private final ResultWriter resultWriter; private final long sampleRateMs; - private final long sampleRateNs; private final List methodsFilteringPrefixes; - private final boolean filteringMethods; private final ThreadMXBean threadBean; - - /** - * The same bean when it offers the bulk CPU time accessor, which reads every thread in one call instead of one native transition per thread. Null when it does not. - */ - private final com.sun.management.ThreadMXBean bulkThreadBean; - private final OperatingSystemMXBean osBean; private final CountDownLatch startupLatch = new CountDownLatch(1); private volatile boolean startupSuccessful = false; @@ -100,13 +92,8 @@ public MonitoringHandler(AgentProperties properties, PowerSource powerSource, Th this.powerSource = powerSource; this.resultWriter = new ResultWriter(properties.getResultsPath()); this.sampleRateMs = Math.max(1L, properties.getSampleRateMs()); - this.sampleRateNs = this.sampleRateMs * NANOS_PER_MILLI; this.methodsFilteringPrefixes = properties.getMethodsFilteringPrefixes(); - this.filteringMethods = !this.methodsFilteringPrefixes.isEmpty(); this.threadBean = threadBean; - this.bulkThreadBean = threadBean instanceof com.sun.management.ThreadMXBean sunThreadBean - ? sunThreadBean - : null; this.osBean = osBean; if (this.sampleRateMs < LOW_SAMPLE_RATE_WARNING_MS) { logger.log(Level.WARNING, @@ -212,6 +199,7 @@ private void runOneWindow(long monitoringThreadId) { // An absolute schedule: each sample is due at a fixed offset from the start of the window, so the time the stack dump takes is absorbed into the interval rather than added on top of it. // Sleeping for the sample rate after the dump instead, quietly turns a configured 10 ms into 10 ms plus the dump, halving the resolution on a JVM with many threads + long sampleRateNs = sampleRateMs * NANOS_PER_MILLI; long nextSampleNs = windowStartNs; int missedSamples = 0; boolean windowRanItsCourse = false; @@ -239,7 +227,7 @@ private void runOneWindow(long monitoringThreadId) { if (!windowRanItsCourse) { // The agent is shutting down and this window was cut off part way through. - // Its handful of samples cover a fraction of a second of a JVM that is already winding down, so attributing them would put a row in the results that looks like a cycle and is not: + // Its handful of samples cover a fraction of a second of a JVM that is already winding down, so attributing them would put a row in the results that looks like a cycle and is not. return; } @@ -303,7 +291,7 @@ private void takeSample(SampleSet samples, long monitoringThreadId) { samples.totalPerThread.merge(threadId, 1, Integer::sum); - if (filteringMethods) { + if (!methodsFilteringPrefixes.isEmpty()) { StringBuilder app = new StringBuilder(estimatedBranchLength(stack)); String all = buildBranch(stack, app); if (!all.isEmpty()) { @@ -333,10 +321,8 @@ private static String buildBranch(StackTraceElement[] stack) { // Presized: the default 16 characters would grow to a couple of kilobytes through about eight array copies for a stack of any depth StringBuilder branch = new StringBuilder(estimatedBranchLength(stack)); for (int i = stack.length - 1; i >= 0; i--) { - if (branch.length() > 0) { - branch.append(';'); - } StackTraceElement element = stack[i]; + appendSeparator(branch); branch.append(element.getClassName()).append('.').append(element.getMethodName()); } return branch.toString(); @@ -365,10 +351,14 @@ private static int estimatedBranchLength(StackTraceElement[] stack) { } private static void append(StringBuilder branch, String methodName) { + appendSeparator(branch); + branch.append(methodName); + } + + private static void appendSeparator(StringBuilder branch) { if (branch.length() > 0) { branch.append(';'); } - branch.append(methodName); } /** What was seen during one monitoring window. */ @@ -428,33 +418,44 @@ private void reportSampling(int missedSamples, int samplesTaken, double interval + String.format(Locale.ROOT, "%.3f", intervalSeconds) + " s, " + missedSamples + " overran the interval"); - if (overrunWarningLogged || samplesTaken <= 0) { + if (samplesTaken <= 0) { return; } - if ((double) missedSamples / samplesTaken < HIGH_OVERRUN_THRESHOLD) { + + if ((double) missedSamples / samplesTaken >= HIGH_OVERRUN_THRESHOLD) { + if (!overrunWarningLogged) { + long achievedMs = Math.round(intervalSeconds * 1000.0 / samplesTaken); + logger.log(Level.WARNING, + () -> "The stack sampler cannot keep up with stack-monitoring-sample-rate=" + + sampleRateMs + " ms: " + samplesTaken + " samples were taken in the last" + + " window, an effective rate of about " + achievedMs + " ms. Dumping the" + + " stacks of this many threads costs more than the interval allows. Raise" + + " the sample rate for an honest one, or accept the coarser data."); + overrunWarningLogged = true; + } return; } - - long achievedMs = Math.round(intervalSeconds * 1000.0 / samplesTaken); - logger.log(Level.WARNING, - () -> "The stack sampler cannot keep up with stack-monitoring-sample-rate=" - + sampleRateMs + " ms: " + samplesTaken + " samples were taken in the last" - + " window, an effective rate of about " + achievedMs + " ms. Dumping the" - + " stacks of this many threads costs more than the interval allows. Raise" - + " the sample rate for an honest one, or accept the coarser data."); - overrunWarningLogged = true; + // Back within budget, so a later spell of overrunning is worth reporting again + overrunWarningLogged = false; } private void reportCoverage(double coverage) { logger.log(Level.FINE, () -> "Window coverage: " + coverage); - if (lowCoverageWarningLogged || coverage <= 0 || coverage >= LOW_COVERAGE_THRESHOLD) { + if (coverage <= 0) { return; } - logger.log(Level.WARNING, - () -> "Only " + Math.round(coverage * 100) + "% of this JVM's CPU time belonged to" - + " threads that were sampled, so the power reported per method is a lower bound. Threads that are short lived, or virtual threads, which the JVM does not report here, are the usual causes. Another cause is a sample rate too coarse for the number of threads."); - lowCoverageWarningLogged = true; + if (coverage < LOW_COVERAGE_THRESHOLD) { + if (!lowCoverageWarningLogged) { + logger.log(Level.WARNING, + () -> "Only " + Math.round(coverage * 100) + "% of this JVM's CPU time belonged to" + + " threads that were sampled, so the power reported per method is a lower bound. Threads that are short lived, or virtual threads, which the JVM does not report here, are the usual causes. Another cause is a sample rate too coarse for the number of threads."); + lowCoverageWarningLogged = true; + } + return; + } + // Coverage recovered, so a later drop is worth reporting again + lowCoverageWarningLogged = false; } /** The power this JVM is drawing: the machine's CPU power times this JVM's share of the work. */ @@ -467,8 +468,7 @@ private double processPower() { /** The share of the machine's busy CPU time that belongs to this JVM */ private double estimateProcessShare() { - // The bean's first reading is negative, which sanitizeCpuLoad turns into zero, so the first window attributes nothing and warms the bean for the ones after it. - // Agent.premain used to do that warming itself and held up the application's own main for a second doing it + // The bean's first reading is negative, which sanitizeCpuLoad turns into zero, so the first window attributes nothing and warms the bean for the ones after it double processCpuLoad = sanitizeCpuLoad(osBean.getProcessCpuLoad()); double systemCpuLoad = sanitizeCpuLoad(osBean.getCpuLoad()); if (processCpuLoad <= 0) { @@ -504,25 +504,13 @@ private double sanitizeCpuLoad(double value) { */ private Map snapshotThreadCpuTime(long excludedThreadId) { long[] ids = threadBean.getAllThreadIds(); - Map snapshot = new HashMap<>(Math.max(16, ids.length * 2)); + // One call for every thread, rather than one native transition per thread + long[] times = threadBean.getThreadCpuTime(ids); - if (bulkThreadBean != null) { - // One call for every thread, rather than one native transition per thread - long[] times = bulkThreadBean.getThreadCpuTime(ids); - for (int i = 0; i < ids.length; i++) { - if (ids[i] != excludedThreadId && times[i] >= 0) { - snapshot.put(ids[i], times[i]); - } - } - } else { - for (long id : ids) { - if (id == excludedThreadId) { - continue; - } - long cpuTime = threadBean.getThreadCpuTime(id); - if (cpuTime >= 0) { - snapshot.put(id, cpuTime); - } + Map snapshot = new HashMap<>(Math.max(16, ids.length * 2)); + for (int i = 0; i < ids.length; i++) { + if (ids[i] != excludedThreadId && times[i] >= 0) { + snapshot.put(ids[i], times[i]); } } return snapshot; diff --git a/src/test/java/org/noureddine/joular/joularcodejava/agent/MonitoringHandlerTest.java b/src/test/java/org/noureddine/joular/joularcodejava/agent/MonitoringHandlerTest.java index 98ef84c..edfe6cc 100644 --- a/src/test/java/org/noureddine/joular/joularcodejava/agent/MonitoringHandlerTest.java +++ b/src/test/java/org/noureddine/joular/joularcodejava/agent/MonitoringHandlerTest.java @@ -12,6 +12,7 @@ package org.noureddine.joular.joularcodejava.agent; import com.sun.management.OperatingSystemMXBean; +import com.sun.management.ThreadMXBean; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -22,7 +23,6 @@ import org.noureddine.joular.joularcodejava.agent.utils.AgentProperties; import java.lang.management.ManagementFactory; -import java.lang.management.ThreadMXBean; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; @@ -79,10 +79,12 @@ class MonitoringHandlerTest { PowerSource mockPowerSource; /** - * Real JDK thread bean. Cannot be mocked without Byte Buddy experimental mode - * due to module encapsulation in JDK 17+. + * Real JDK thread bean (com.sun.management flavour required by MonitoringHandler). + * Cannot be mocked without Byte Buddy experimental mode due to module + * encapsulation in JDK 17+. */ - private final ThreadMXBean threadBean = ManagementFactory.getThreadMXBean(); + private final ThreadMXBean threadBean = + (ThreadMXBean) ManagementFactory.getThreadMXBean(); /** * Real JDK OS bean (com.sun.management flavour required by MonitoringHandler). From e8ce565cfab98a4c9d0622ead061758b1f2bb614 Mon Sep 17 00:00:00 2001 From: Adel Noureddine Date: Tue, 22 Sep 2026 15:59:44 +0200 Subject: [PATCH 4/7] fix monitoring windows policy to powersource --- README.md | 3 +- .../agent/MonitoringHandler.java | 27 +------ .../agent/source/PowerJoularCsvSource.java | 2 + .../source/PowerJoularRingBufferSource.java | 67 +++++++++++++++- .../agent/source/PowerSource.java | 30 ++++++-- .../PowerJoularRingBufferSourceTest.java | 76 +++++++++++++++++-- 6 files changed, 162 insertions(+), 43 deletions(-) diff --git a/README.md b/README.md index fd02057..eacff97 100644 --- a/README.md +++ b/README.md @@ -201,7 +201,7 @@ timestamp,branch,power_watts,energy_joules,interval_seconds,coverage 1746000001000,com.example.Main.main;com.example.Worker.compute,2.158300000,2.158300000,1.000000000,0.9974 ``` -The cycle is aligned to PowerJoular when the ring buffer is used: the agent closes a cycle the moment PowerJoular publishes a measurement, so the stack samples and the power describe the same second. With the CSV or RAPL sources the agent keeps its own one second cycle instead. +Each power source decides when a cycle closes, because each one knows its own cadence. With the ring buffer the agent closes a cycle the moment PowerJoular publishes a measurement, so the stack samples and the power describe the same second; if PowerJoular stops publishing, the agent says so once and falls back to a fixed one second cycle until it returns. The CSV and RAPL sources always use that fixed one second cycle: RAPL has no cycle of its own, and recognising the CSV's would mean re-parsing the file on every stack sample. ## :warning: Troubleshooting @@ -210,6 +210,7 @@ The cycle is aligned to PowerJoular when the ring buffer is used: the agent clos - **"Could not read power data from ..."** (`WARNING` log): the configured `powerjoular-csv-path` does not exist. Check that PowerJoular is running with `-f` or `-o` and writing to the same path. The warning is logged only once until the file appears. - **"The stack sampler cannot keep up ..."** (`WARNING` log): dumping the stacks of this many threads costs more than `stack-monitoring-sample-rate` allows, so fewer samples were taken than configured. The message gives the rate actually achieved. Raise the sample rate for an honest figure, or accept the coarser data; the energy totals stay correct either way, they are just based on fewer samples. - **"Only N% of this JVM's CPU time belonged to threads that were sampled"** (`WARNING` log): see the `coverage` column above. The rows are a lower bound rather than wrong. +- **"... has published no new cycle for 2000 ms"** (`WARNING` log): PowerJoular's ring buffer counter has stopped moving, so the agent gave up aligning its cycles to it and went back to a fixed one second cycle. Results keep coming at the usual rate, but each cycle and the power charged to it may describe slightly different seconds. Check PowerJoular is still running with `-r`; the agent logs again at `INFO` when it starts publishing and the cycles realign. - **"... is the file PowerJoular writes for one monitored process"** (`SEVERE` log): the CSV has three columns, so it holds the power of a single process, which the agent would scale a second time. Point `powerjoular-csv-path` at the file for the whole system instead. - **RAPL mode fails at startup**: `power-source-type=rapl` is Linux-only and requires readable `/sys/class/powercap/intel-rapl/intel-rapl:0/name`, `energy_uj`, and `max_energy_range_uj` files for the `package-0` domain. Depending on the system, this may require elevated permissions. - **No rows in `methods-power-app.csv`**: if `methods-filtering-prefix` is set, the configured prefix may not match any fully-qualified method name in your application. If it is unset, all observed methods are eligible for both output files. diff --git a/src/main/java/org/noureddine/joular/joularcodejava/agent/MonitoringHandler.java b/src/main/java/org/noureddine/joular/joularcodejava/agent/MonitoringHandler.java index c8850b8..6b2c202 100644 --- a/src/main/java/org/noureddine/joular/joularcodejava/agent/MonitoringHandler.java +++ b/src/main/java/org/noureddine/joular/joularcodejava/agent/MonitoringHandler.java @@ -58,14 +58,6 @@ public class MonitoringHandler implements Runnable { private static final long NANOS_PER_SECOND = 1_000_000_000L; private static final long LOW_SAMPLE_RATE_WARNING_MS = 5L; - /** How long a window runs when the power source publishes no cycle of its own. */ - private static final long DEFAULT_WINDOW_NS = 1000L * NANOS_PER_MILLI; - - /** - * The longest a window may run while waiting for the power source to publish a cycle, so that a producer which has stopped cannot stretch one window out for ever (mainly for ring buffers). - */ - private static final long MAX_WINDOW_NS = 2000L * NANOS_PER_MILLI; - /** Below this share of the JVM's CPU time being accounted for, the results are worth a warning. */ private static final double LOW_COVERAGE_THRESHOLD = 0.5; @@ -195,7 +187,7 @@ private void runOneWindow(long monitoringThreadId) { Map windowStartCpuTime = snapshotThreadCpuTime(monitoringThreadId); SampleSet samples = new SampleSet(); - long openingCycle = powerSource.cycleCounter(); + powerSource.beginWindow(); // An absolute schedule: each sample is due at a fixed offset from the start of the window, so the time the stack dump takes is absorbed into the interval rather than added on top of it. // Sleeping for the sample rate after the dump instead, quietly turns a configured 10 ms into 10 ms plus the dump, halving the resolution on a JVM with many threads @@ -206,7 +198,7 @@ private void runOneWindow(long monitoringThreadId) { while (running && !Thread.currentThread().isInterrupted()) { long now = System.nanoTime(); - if (windowIsComplete(now - windowStartNs, openingCycle)) { + if (powerSource.isWindowComplete(now - windowStartNs)) { windowRanItsCourse = true; break; } @@ -254,21 +246,6 @@ private void runOneWindow(long monitoringThreadId) { nowMs, intervalSeconds, attribution.coverage(), "methods-power-app.csv"); } - /** - * Whether the window has completed. - * - *

When the power source publishes a cycle counter, the window ends the moment that counter moves, so that the agent's window and the producer's measurement (ring buffer mainly) cover the same stretch of time. - * The producer's value at T is the average over the second up to T, and closing the window on that same edge is what makes the samples describe that same second. - * Without a counter the window is simply a second long, which leaves the two free to drift apart by up to a second. - */ - private boolean windowIsComplete(long elapsedNs, long openingCycle) { - if (openingCycle < 0) { - return elapsedNs >= DEFAULT_WINDOW_NS; - } - // The cap keeps a producer that has stopped from stretching one window out forever - return powerSource.cycleCounter() != openingCycle || elapsedNs >= MAX_WINDOW_NS; - } - /** One pass over every live thread, recording the branch each runnable one is on */ private void takeSample(SampleSet samples, long monitoringThreadId) { // dumpAllThreads avoids the Map wrapping that Thread.getAllStackTraces() builds on top of the same native call diff --git a/src/main/java/org/noureddine/joular/joularcodejava/agent/source/PowerJoularCsvSource.java b/src/main/java/org/noureddine/joular/joularcodejava/agent/source/PowerJoularCsvSource.java index e89399a..59683ab 100644 --- a/src/main/java/org/noureddine/joular/joularcodejava/agent/source/PowerJoularCsvSource.java +++ b/src/main/java/org/noureddine/joular/joularcodejava/agent/source/PowerJoularCsvSource.java @@ -32,6 +32,8 @@ *

{@code -f} adds a row every second and starts the file with the header. {@code -o} rewrites the file every second with the latest row only and no header, which is the better option here. Both are supported: the file is recognised by how many columns its rows carry, so the header is only ever skipped over and never has to be parsed. * *

Monitoring a process makes PowerJoular write a second, three column file holding the power of that process alone. That file is rejected here. + * + *

PowerJoular writes here once a second, so this source does have a cadence of its own, but it keeps the default fixed window all the same: recognising the edge would mean parsing the file on every sample tick, and {@link PowerSource#isWindowComplete(long)} has to stay cheap. The agent's windows and the rows therefore drift by up to a second relative to each other, which is the price of reading this format. The ring buffer publishes a counter that can be read without parsing, so it aligns properly. */ public class PowerJoularCsvSource implements PowerSource { diff --git a/src/main/java/org/noureddine/joular/joularcodejava/agent/source/PowerJoularRingBufferSource.java b/src/main/java/org/noureddine/joular/joularcodejava/agent/source/PowerJoularRingBufferSource.java index 9bc7df1..32cec7a 100644 --- a/src/main/java/org/noureddine/joular/joularcodejava/agent/source/PowerJoularRingBufferSource.java +++ b/src/main/java/org/noureddine/joular/joularcodejava/agent/source/PowerJoularRingBufferSource.java @@ -66,6 +66,12 @@ public class PowerJoularRingBufferSource implements PowerSource { */ static final long MAX_ENTRY_AGE_SECONDS = 10; + /** + * The longest a window waits for the next cycle before giving up on aligning to PowerJoular. + * It writes once a second, so twice that is already well past due. + */ + static final long MAX_WINDOW_NANOS = 2_000_000_000L; + private final String path; private volatile ByteBuffer buffer; @@ -73,6 +79,12 @@ public class PowerJoularRingBufferSource implements PowerSource { private int staleCycles = 0; private boolean attachWarningLogged = false; + /** The counter as it stood when the current window opened, or -1 when there was nothing to follow. */ + private long windowOpeningCycle = -1; + + /** Set once the producer has missed its cycle, so windows stop waiting on a counter that is not moving. */ + private boolean producerStalled = false; + public PowerJoularRingBufferSource(String path) { this.path = path; } @@ -111,12 +123,63 @@ public double getCurrentPower() { } @Override - public long cycleCounter() { - // PowerJoular raises the counter once it has written a cycle, so a change here is exactly the edge the agent wants to close its window on. + public void beginWindow() { + windowOpeningCycle = headCounter(); + } + + /** + * Closes the window on the edge where PowerJoular publishes its next cycle, so the stack samples in the window describe the same second as the power charged to them. + * + *

If that edge does not arrive within {@link #MAX_WINDOW_NANOS} the producer has stopped, and waiting on it would stretch every window from then on. + * The source says so once and falls back to the plain fixed window until the counter moves again, so a dead PowerJoular costs the alignment rather than the cadence. + */ + @Override + public boolean isWindowComplete(long elapsedNs) { + if (windowOpeningCycle <= 0) { + // Not attached, or PowerJoular has yet to write its first cycle: there is no edge to follow + return elapsedNs >= DEFAULT_WINDOW_NANOS; + } + + if (headCounter() != windowOpeningCycle) { + markProducerPublishing(); + return true; + } + + if (producerStalled) { + return elapsedNs >= DEFAULT_WINDOW_NANOS; + } + + if (elapsedNs >= MAX_WINDOW_NANOS) { + markProducerStalled(); + return true; + } + return false; + } + + /** PowerJoular raises the counter once it has written a cycle, so a change is a published measurement. */ + private long headCounter() { ByteBuffer area = this.buffer; return area == null ? -1 : area.getLong(0); } + private void markProducerStalled() { + if (!producerStalled) { + producerStalled = true; + logger.log(Level.WARNING, + () -> "The PowerJoular ring buffer " + path + " has published no new cycle for " + + MAX_WINDOW_NANOS / 1_000_000L + " ms, so monitoring windows are no longer aligned to it and fall back to a fixed " + + DEFAULT_WINDOW_NANOS / 1_000_000L + " ms. Is PowerJoular stillrunning with -r? Results stay on their usual cadence, but each window and the power charged to it may now describe slightly different seconds."); + } + } + + private void markProducerPublishing() { + if (producerStalled) { + producerStalled = false; + logger.log(Level.INFO, + () -> "The PowerJoular ring buffer " + path + " is publishing cycles again. Monitoring windows are realigned to it."); + } + } + /** * Reads cpu_power from the newest complete entry. * diff --git a/src/main/java/org/noureddine/joular/joularcodejava/agent/source/PowerSource.java b/src/main/java/org/noureddine/joular/joularcodejava/agent/source/PowerSource.java index b54498d..356d8a7 100644 --- a/src/main/java/org/noureddine/joular/joularcodejava/agent/source/PowerSource.java +++ b/src/main/java/org/noureddine/joular/joularcodejava/agent/source/PowerSource.java @@ -51,19 +51,35 @@ public interface PowerSource { */ double getCurrentPower(); + /** How long an attribution window runs for a source that has no cadence of its own to follow + */ + long DEFAULT_WINDOW_NANOS = 1_000_000_000L; + + /** + * Called by the agent as it opens an attribution window, before the first stack sample. + * + *

A source that closes its windows on its producer's cadence latches here whatever it needs to recognise the next measurement. + * The default does nothing, which is right for a source whose windows are simply a fixed length. + */ + default void beginWindow() { + // Nothing to latch: the default policy is a fixed-length window + } + /** - * Counter incremented by the producer on each new measurement, or -1 if it has no such counter. + * Whether the attribution window opened by the last {@link #beginWindow()}, and running for {@code elapsedNs} so far, should be closed now. * - *

The agent ends its attribution window when this value changes, so the window covers the same time span as the producer's measurement. - * PowerJoular reports the average power over the second ending at T, so the stack samples in that window belong to the same second as the power they are charged with. - * If this returns -1, the agent uses its own one second timer instead, which can drift relative to the producer. + *

This is where a source decides how its own cadence maps onto the agent's windows, because it is the only party that knows that cadence. + * A source fed by an external producer that publishes an average over the second ending at T closes the window on the edge where that value lands, so the stack samples in the window describe the same second as the power they are charged with. + * It also owns any bound on that wait, since the sensible bound follows from the cadence. + * The default is the fixed {@link #DEFAULT_WINDOW_NANOS} window, which is right for a source read on demand rather than published to. * *

Called on every sample tick, so it must be cheap and must not block. * - * @return the producer's cycle counter, or -1 when unknown + * @param elapsedNs nanoseconds since {@link #beginWindow()} was called + * @return {@code true} to close the window now */ - default long cycleCounter() { - return -1; + default boolean isWindowComplete(long elapsedNs) { + return elapsedNs >= DEFAULT_WINDOW_NANOS; } /** diff --git a/src/test/java/org/noureddine/joular/joularcodejava/agent/source/PowerJoularRingBufferSourceTest.java b/src/test/java/org/noureddine/joular/joularcodejava/agent/source/PowerJoularRingBufferSourceTest.java index f3c4290..eb69876 100644 --- a/src/test/java/org/noureddine/joular/joularcodejava/agent/source/PowerJoularRingBufferSourceTest.java +++ b/src/test/java/org/noureddine/joular/joularcodejava/agent/source/PowerJoularRingBufferSourceTest.java @@ -24,6 +24,8 @@ import static org.junit.jupiter.api.Assertions.*; import static org.noureddine.joular.joularcodejava.agent.source.PowerJoularRingBufferSource.ENTRY_SIZE; import static org.noureddine.joular.joularcodejava.agent.source.PowerJoularRingBufferSource.FILE_SIZE; +import static org.noureddine.joular.joularcodejava.agent.source.PowerJoularRingBufferSource.MAX_WINDOW_NANOS; +import static org.noureddine.joular.joularcodejava.agent.source.PowerSource.DEFAULT_WINDOW_NANOS; import static org.noureddine.joular.joularcodejava.agent.source.PowerSource.MAX_STALE_CYCLES; import static org.noureddine.joular.joularcodejava.agent.source.TestLogging.quietly; @@ -262,27 +264,85 @@ void close_withoutInitialize_isSafe() { } // ------------------------------------------------------------------------- - // cycleCounter: the edge the agent closes its attribution window on + // isWindowComplete: the edge the agent closes its attribution window on // ------------------------------------------------------------------------- - /** Unattached, the source has no cycle to offer and the agent falls back to its own timer. */ + /** Unattached, there is no cadence to follow, so windows are simply the default length. */ @Test - void cycleCounter_notAttached_returnsMinusOne() throws Exception { + void isWindowComplete_notAttached_usesTheFixedWindow() throws Exception { PowerJoularRingBufferSource source = new PowerJoularRingBufferSource( tempDir.resolve("nonexistent").toString()); quietly(PowerJoularRingBufferSource.class, () -> { source.initialize(); - assertEquals(-1, source.cycleCounter()); + source.beginWindow(); + assertFalse(source.isWindowComplete(DEFAULT_WINDOW_NANOS - 1)); + assertTrue(source.isWindowComplete(DEFAULT_WINDOW_NANOS)); }); } - /** Attached, it reports PowerJoular's counter, and follows it as cycles are written. */ + /** + * An area that exists but holds no cycle yet is PowerJoular still starting up, not a stall. + * The window runs its default length and nothing is warned about. + */ @Test - void cycleCounter_attached_followsTheProducer() throws Exception { + void isWindowComplete_noCycleWrittenYet_usesTheFixedWindowQuietly() throws Exception { + PowerJoularRingBufferSource source = sourceFor(writeTempRingBuffer(0L, 0, 0.0)); + source.beginWindow(); + + assertFalse(source.isWindowComplete(DEFAULT_WINDOW_NANOS - 1)); + assertTrue(source.isWindowComplete(DEFAULT_WINDOW_NANOS)); + assertTrue(source.isWindowComplete(MAX_WINDOW_NANOS), + "waiting past the cap must not be treated as a stall before the first cycle"); + } + + /** The window stays open until PowerJoular publishes, then closes on that edge. */ + @Test + void isWindowComplete_closesOnTheProducersEdge() throws Exception { PowerJoularRingBufferSource source = sourceFor(writeTempRingBuffer(4L, 3, 12.0)); - assertEquals(4L, source.cycleCounter()); + source.beginWindow(); + + assertFalse(source.isWindowComplete(DEFAULT_WINDOW_NANOS), + "past a second but the producer has not published, so keep waiting for its edge"); writeTempRingBuffer(5L, 4, 13.0); - assertEquals(5L, source.cycleCounter(), "a new cycle must be visible to the agent"); + assertTrue(source.isWindowComplete(1L), "a new cycle closes the window immediately"); + } + + /** + * A producer that has stopped must not stretch window after window to the cap. + * The first overrun is reported, and windows fall back to the fixed length until it publishes again. + */ + @Test + void isWindowComplete_stalledProducer_warnsOnceThenUsesTheFixedWindow() throws Exception { + PowerJoularRingBufferSource source = sourceFor(writeTempRingBuffer(4L, 3, 12.0)); + + quietly(PowerJoularRingBufferSource.class, () -> { + source.beginWindow(); + assertTrue(source.isWindowComplete(MAX_WINDOW_NANOS), "the cap ends the stalled wait"); + + // Every window after it keeps the plain cadence instead of waiting out the cap again + source.beginWindow(); + assertTrue(source.isWindowComplete(DEFAULT_WINDOW_NANOS)); + }); + } + + /** Once the producer comes back, windows realign to its edge. */ + @Test + void isWindowComplete_producerRecovers_realignsToItsEdge() throws Exception { + PowerJoularRingBufferSource source = sourceFor(writeTempRingBuffer(4L, 3, 12.0)); + + quietly(PowerJoularRingBufferSource.class, () -> { + source.beginWindow(); + assertTrue(source.isWindowComplete(MAX_WINDOW_NANOS)); + + // The producer publishes again part way through the next window + source.beginWindow(); + writeTempRingBuffer(5L, 4, 13.0); + assertTrue(source.isWindowComplete(1L), "the edge is followed again"); + + // Back to waiting for the producer rather than settling for the fixed length + source.beginWindow(); + assertFalse(source.isWindowComplete(DEFAULT_WINDOW_NANOS)); + }); } } From d30aeeaa79170020bc033ea05f6770dbca8bb7bd Mon Sep 17 00:00:00 2001 From: Adel Noureddine Date: Tue, 22 Sep 2026 16:14:16 +0200 Subject: [PATCH 5/7] updates --- .github/workflows/maven.yml | 2 +- README.md | 20 +- pom.xml | 269 +++++++++--------- .../joular/joularcodejava/agent/Agent.java | 51 +++- .../source/PowerJoularRingBufferSource.java | 2 +- .../agent/AgentEntryPointTest.java | 60 ++++ 6 files changed, 269 insertions(+), 135 deletions(-) create mode 100644 src/test/java/org/noureddine/joular/joularcodejava/agent/AgentEntryPointTest.java diff --git a/.github/workflows/maven.yml b/.github/workflows/maven.yml index b3cbb16..9f2456f 100644 --- a/.github/workflows/maven.yml +++ b/.github/workflows/maven.yml @@ -29,7 +29,7 @@ jobs: cache: maven - name: Build and test with Maven - run: mvn -B verify --file pom.xml + run: mvn -B -ntp verify --file pom.xml - name: Upload test results if: always() diff --git a/README.md b/README.md index eacff97..c0e3dc3 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ The agent has no runtime dependencies, so the JAR holds nothing but its own clas ## :bulb: Usage -Joular Code - Java attaches to your Java application as a Java agent. Start [PowerJoular](https://github.com/joular/powerjoular) first when using `ringbuffer` or `csv`; on Linux, `rapl` can read CPU package power directly from the powercap interface. +Joular Code - Java attaches to your Java application as a Java agent, either on the command line with `-javaagent:` or to a JVM that is already running. Start [PowerJoular](https://github.com/joular/powerjoular) first when using `ringbuffer` or `csv`; on Linux, `rapl` can read CPU package power directly from the powercap interface. PowerJoular measures the hardware through the [Joular Core](https://github.com/joular/joularcore) library. @@ -77,6 +77,24 @@ java -javaagent:joularcodejava-.jar YourMainClass java -javaagent:joularcodejava-.jar -jar yourApplication.jar ``` +### Attaching to a JVM that is already running + +The agent can also be loaded into a running JVM through the Attach API, without restarting it: + +```java +VirtualMachine vm = VirtualMachine.attach(pid); +vm.loadAgent("/path/to/joularcodejava-.jar"); +vm.detach(); +``` + +Monitoring covers the JVM from the moment it attaches, so whatever the application did before that is not in the results. The target JVM has to allow it, which recent versions only do when started with `-XX:+EnableDynamicAgentLoading`: + +```bash +java -XX:+EnableDynamicAgentLoading -jar yourApplication.jar +``` + +Configuration is read when the agent attaches, so `-Djoularcodejava.properties=...` belongs on the target JVM's own command line. Attaching a second time is ignored, with a warning, rather than starting a second monitor. + ### Specifying a custom configuration file By default, Joular Code - Java looks for `joularcodejava.properties` in the current working directory. To specify a custom path: diff --git a/pom.xml b/pom.xml index 16916e3..969b40e 100644 --- a/pom.xml +++ b/pom.xml @@ -1,127 +1,142 @@ - - - - - - 4.0.0 - - org.noureddine.joular - joularcodejava - 0.0.1 - jar - - ${project.artifactId} - Joular Code Java is a lightweight and efficient Java agent for monitoring the energy consumption of methods and executions branches - 2026 - https://github.com/joular/joularcode-java - - - - The GNU Lesser General Public License v3.0 (LGPL-3.0-only) - https://www.gnu.org/licenses/lgpl-3.0.en.html - - - - - - Adel Noureddine - adel.noureddine@outlook.com - - - - - scm:git:git://github.com/joular/joularcode-java.git - scm:git:ssh://github.com:joular/joularcode-java.git - https://github.com/joular/joularcode-java - - - - 21 - UTF-8 - UTF-8 - 5.23.0 - - - - - org.junit.jupiter - junit-jupiter - 6.1.3 - test - - - org.mockito - mockito-junit-jupiter - ${mockito.version} - test - - - - - - - org.apache.maven.plugins - maven-enforcer-plugin - 3.6.3 - - - enforce-java-version - - enforce - - - - - [21,) - Joular Code - Java requires Java 21 or later. - - - - - - - - org.apache.maven.plugins - maven-compiler-plugin - 3.16.0 - - - org.apache.maven.plugins - maven-surefire-plugin - 3.6.0 - - -Xshare:off -javaagent:${settings.localRepository}/org/mockito/mockito-core/${mockito.version}/mockito-core-${mockito.version}.jar -Dnet.bytebuddy.experimental=true - true - - - - org.apache.maven.plugins - maven-jar-plugin - 3.5.1 - - true - - - org.noureddine.joular.joularcodejava.agent.Agent - org.noureddine.joular.joularcodejava.agent.Agent - true - true - ${project.version} - - - - - - - + + + + + + 4.0.0 + + org.noureddine.joular + joularcodejava + 0.0.1 + jar + + ${project.artifactId} + Joular Code Java is a lightweight and efficient Java agent for monitoring the energy consumption of methods and executions branches + 2026 + https://github.com/joular/joularcode-java + + + + The GNU Lesser General Public License v3.0 (LGPL-3.0-only) + https://www.gnu.org/licenses/lgpl-3.0.en.html + + + + + + Adel Noureddine + adel.noureddine@outlook.com + + + + + scm:git:git://github.com/joular/joularcode-java.git + scm:git:ssh://github.com:joular/joularcode-java.git + https://github.com/joular/joularcode-java + + + + 21 + UTF-8 + UTF-8 + 5.23.0 + + + + + + + org.junit.jupiter + junit-jupiter + 6.1.3 + test + + + org.mockito + mockito-junit-jupiter + ${mockito.version} + test + + + + + + + org.apache.maven.plugins + maven-enforcer-plugin + 3.6.3 + + + enforce-java-version + + enforce + + + + + [21,) + Joular Code - Java requires Java 21 or later. + + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.16.0 + + + + org.apache.maven.plugins + maven-dependency-plugin + 3.11.0 + + + resolve-dependency-paths + + properties + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.6.0 + + + @{argLine} -Xshare:off -javaagent:${org.mockito:mockito-core:jar} -Dnet.bytebuddy.experimental=true + true + + + + org.apache.maven.plugins + maven-jar-plugin + 3.5.1 + + true + + + org.noureddine.joular.joularcodejava.agent.Agent + org.noureddine.joular.joularcodejava.agent.Agent + ${project.version} + + + + + + + diff --git a/src/main/java/org/noureddine/joular/joularcodejava/agent/Agent.java b/src/main/java/org/noureddine/joular/joularcodejava/agent/Agent.java index cf0396c..0c95dff 100644 --- a/src/main/java/org/noureddine/joular/joularcodejava/agent/Agent.java +++ b/src/main/java/org/noureddine/joular/joularcodejava/agent/Agent.java @@ -17,6 +17,7 @@ import java.io.PrintWriter; import java.io.StringWriter; import java.util.Locale; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.logging.Formatter; import java.util.logging.Handler; import java.util.logging.Level; @@ -35,6 +36,11 @@ public class Agent { private static final Logger logger = Logger.getLogger( Agent.class.getName()); + /** + * Whether the agent holds this JVM. The Attach API can be asked to load an agent as often as anyone likes, and a second monitoring thread would sample the same JVM twice and write both sets of rows into the same files. + */ + private static final AtomicBoolean started = new AtomicBoolean(false); + private static void printJoularCodeJavaBanner() { String version = Agent.class.getPackage() != null ? Agent.class.getPackage().getImplementationVersion() @@ -56,7 +62,41 @@ private static void printJoularCodeJavaBanner() { System.out.println(boldYellow + welcomeMessage + reset); } + /** Entry point when the agent is on the command line, as {@code -javaagent:joularcodejava.jar}. */ public static void premain(String args, Instrumentation inst) { + start(); + } + + /** + * Entry point when the agent is loaded into a JVM that is already running, through the Attach + * API ({@code VirtualMachine.loadAgent}) or a tool built on it. + * + *

Monitoring covers the JVM from the moment it attaches, so whatever the application did + * before that is not in the results. Note that a JVM only accepts an agent this way when it was + * started with {@code -XX:+EnableDynamicAgentLoading}, or is old enough to still allow it by default. + */ + public static void agentmain(String args, Instrumentation inst) { + start(); + } + + private static void start() { + if (!started.compareAndSet(false, true)) { + logger.log(Level.WARNING, + "Joular Code - Java is already monitoring this JVM. Ignoring this attach."); + return; + } + if (!startMonitoring()) { + // Nothing was left running, so a later attach is free to try again + started.set(false); + } + } + + /** + * Brings the agent up. + * + * @return {@code true} when a monitoring thread was left running, {@code false} when startup could not proceed and this JVM is unchanged. + */ + private static boolean startMonitoring() { configureLogging(); printJoularCodeJavaBanner(); logger.log(Level.INFO, "Initializing Joular Code Java..."); @@ -66,13 +106,13 @@ public static void premain(String args, Instrumentation inst) { if (powerSource == null) { logger.log(Level.SEVERE, "No valid power source found. Agent will not start."); - return; + return false; } if (!(ManagementFactory.getThreadMXBean() instanceof com.sun.management.ThreadMXBean threadBean)) { logger.log(Level.SEVERE, "Unsupported JVM: requires com.sun.management.ThreadMXBean to read every thread's CPU time in one call. Joular Code - Java will not start."); - return; + return false; } if (threadBean.isThreadCpuTimeSupported()) { if (!threadBean.isThreadCpuTimeEnabled()) { @@ -81,14 +121,14 @@ public static void premain(String args, Instrumentation inst) { } else { logger.log(Level.SEVERE, "Thread CPU time is not supported on this JVM. Joular Code - Java will not start."); - return; + return false; } OperatingSystemMXBean osBean = ManagementFactory.getOperatingSystemMXBean(); if (!(osBean instanceof com.sun.management.OperatingSystemMXBean sunOsBean)) { logger.log(Level.SEVERE, "Unsupported JVM: requires com.sun.management.OperatingSystemMXBean for CPU-load metrics. Joular Code - Java will not start."); - return; + return false; } // The OS bean reports its loads over the interval since the previous call, so the first reading is meaningless and comes back negative. @@ -109,7 +149,7 @@ public static void premain(String args, Instrumentation inst) { if (startupSignaled && !monitoringHandler.isStartupSuccessful()) { Throwable startupFailure = monitoringHandler.getStartupFailure(); logger.log(Level.SEVERE, "Monitoring thread failed during startup.", startupFailure); - return; + return false; } if (!startupSignaled) { logger.log(Level.WARNING, @@ -124,6 +164,7 @@ public static void premain(String args, Instrumentation inst) { })); logger.log(Level.INFO, "Joular Code Java started successfully."); + return true; } private static void configureLogging() { diff --git a/src/main/java/org/noureddine/joular/joularcodejava/agent/source/PowerJoularRingBufferSource.java b/src/main/java/org/noureddine/joular/joularcodejava/agent/source/PowerJoularRingBufferSource.java index 32cec7a..e2c2a5e 100644 --- a/src/main/java/org/noureddine/joular/joularcodejava/agent/source/PowerJoularRingBufferSource.java +++ b/src/main/java/org/noureddine/joular/joularcodejava/agent/source/PowerJoularRingBufferSource.java @@ -168,7 +168,7 @@ private void markProducerStalled() { logger.log(Level.WARNING, () -> "The PowerJoular ring buffer " + path + " has published no new cycle for " + MAX_WINDOW_NANOS / 1_000_000L + " ms, so monitoring windows are no longer aligned to it and fall back to a fixed " - + DEFAULT_WINDOW_NANOS / 1_000_000L + " ms. Is PowerJoular stillrunning with -r? Results stay on their usual cadence, but each window and the power charged to it may now describe slightly different seconds."); + + DEFAULT_WINDOW_NANOS / 1_000_000L + " ms. Is PowerJoular still running with -r? Results stay on their usual cadence, but each window and the power charged to it may now describe slightly different seconds."); } } diff --git a/src/test/java/org/noureddine/joular/joularcodejava/agent/AgentEntryPointTest.java b/src/test/java/org/noureddine/joular/joularcodejava/agent/AgentEntryPointTest.java new file mode 100644 index 0000000..3fb6b8b --- /dev/null +++ b/src/test/java/org/noureddine/joular/joularcodejava/agent/AgentEntryPointTest.java @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2026, Adel Noureddine. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the + * GNU Lesser General Public License v3.0 (LGPL-3.0-only) + * which accompanies this distribution, and is available at + * https://www.gnu.org/licenses/lgpl-3.0.en.html + * + * Author : Adel Noureddine + */ + +package org.noureddine.joular.joularcodejava.agent; + +import org.junit.jupiter.api.Test; + +import java.lang.instrument.Instrumentation; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Guards the two entry points the JVM looks up by name. + * + *

The manifest names this class for both {@code Premain-Class} and {@code Agent-Class}, and the + * JVM resolves the matching method reflectively at load time. Nothing in the Java compiler ties the + * two together, so the manifest once advertised {@code Agent-Class} while no {@code agentmain} + * existed: every attach to a running JVM failed, and no test noticed. These assertions are the + * missing link, and they fail at build time rather than at someone else's attach. + * + *

They deliberately only inspect the signatures. Calling either method starts a monitoring + * thread and writes result files, which belongs in an integration run rather than a unit test. + */ +class AgentEntryPointTest { + + /** Loaded by the JVM when the agent is given on the command line with {@code -javaagent:}. */ + @Test + void premain_hasTheSignatureTheJvmLooksUp() { + assertIsAgentEntryPoint("premain"); + } + + /** Loaded by the JVM when the agent is attached to an already running process. */ + @Test + void agentmain_hasTheSignatureTheAttachApiLooksUp() { + assertIsAgentEntryPoint("agentmain"); + } + + private static void assertIsAgentEntryPoint(String name) { + Method method = assertDoesNotThrow( + () -> Agent.class.getMethod(name, String.class, Instrumentation.class), + name + "(String, Instrumentation) must exist for the JVM to find it"); + + int modifiers = method.getModifiers(); + assertTrue(Modifier.isPublic(modifiers), name + " must be public"); + assertTrue(Modifier.isStatic(modifiers), name + " must be static"); + assertEquals(void.class, method.getReturnType(), name + " must return void"); + } +} From 2a29d1b363d20946a630dc27451a8878d7fe0104 Mon Sep 17 00:00:00 2001 From: Adel Noureddine Date: Tue, 22 Sep 2026 16:16:50 +0200 Subject: [PATCH 6/7] updates --- .gitattributes | 6 +++++ README.md | 22 +++++++++---------- pom.xml | 2 +- .../joular/joularcodejava/agent/Agent.java | 12 +++++----- 4 files changed, 24 insertions(+), 18 deletions(-) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..45e1632 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,6 @@ +# Keep all files to LF file endings +* text=auto eol=lf + +# Binary formats, never end-of-line converted. The PowerJoular ring buffer is a packed byte layout read field by field, so a .bin fixture must survive untouched. +*.jar binary +*.bin binary diff --git a/README.md b/README.md index c0e3dc3..3ff2a17 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,9 @@ -# Joular Project Joular Code - Java +# Joular Project Joular Code for Java [![License: LGPL v3](https://img.shields.io/badge/License-LGPL%20v3-blue.svg)](https://www.gnu.org/licenses/lgpl-3.0) [![Java](https://img.shields.io/badge/Java-21%2B-orange)](https://openjdk.java.net) -Joular Code - Java is a lightweight and efficient Java agent for monitoring the energy consumption of methods and execution branches at the source code level. +Joular Code for Java is a lightweight and efficient Java agent for monitoring the energy consumption of methods and execution branches at the source code level. This project is part of [Joular Code](https://github.com/joular/joularcode), and is the successor of [JoularJX](https://github.com/joular/joularjx). @@ -24,7 +24,7 @@ This project is part of [Joular Code](https://github.com/joular/joularcode), and ## :bulb: How It Works -Joular Code - Java runs as a Java instrumentation agent alongside your application. Every monitoring cycle (default: 1 second), it: +Joular Code for Java runs as a Java instrumentation agent alongside your application. Every monitoring cycle (default: 1 second), it: 1. **Samples the JVM stack**: every `stack-monitoring-sample-rate` milliseconds, it captures the stack trace of every `RUNNABLE` thread, building sample counts for each call branch. 2. **Measures thread CPU time**: it takes CPU-time snapshots at the start and end of each cycle using `ThreadMXBean`, computing how much CPU time each thread consumed during the window. @@ -61,7 +61,7 @@ The agent has no runtime dependencies, so the JAR holds nothing but its own clas ## :bulb: Usage -Joular Code - Java attaches to your Java application as a Java agent, either on the command line with `-javaagent:` or to a JVM that is already running. Start [PowerJoular](https://github.com/joular/powerjoular) first when using `ringbuffer` or `csv`; on Linux, `rapl` can read CPU package power directly from the powercap interface. +Joular Code for Java attaches to your Java application as a Java agent, either on the command line with `-javaagent:` or to a JVM that is already running. Start [PowerJoular](https://github.com/joular/powerjoular) first when using `ringbuffer` or `csv`; on Linux, `rapl` can read CPU package power directly from the powercap interface. PowerJoular measures the hardware through the [Joular Core](https://github.com/joular/joularcore) library. @@ -97,7 +97,7 @@ Configuration is read when the agent attaches, so `-Djoularcodejava.properties=. ### Specifying a custom configuration file -By default, Joular Code - Java looks for `joularcodejava.properties` in the current working directory. To specify a custom path: +By default, Joular Code for Java looks for `joularcodejava.properties` in the current working directory. To specify a custom path: ```bash java -Djoularcodejava.properties=/path/to/joularcodejava.properties -javaagent:joularcodejava-.jar -jar yourApplication.jar @@ -105,7 +105,7 @@ java -Djoularcodejava.properties=/path/to/joularcodejava.properties -javaagent:j ## :gear: Configuration -All configuration is done via a `joularcodejava.properties` file. Joular Code - Java loads it from the following locations in order: +All configuration is done via a `joularcodejava.properties` file. Joular Code for Java loads it from the following locations in order: 1. Path specified by the `-Djoularcodejava.properties=` JVM property 2. `joularcodejava.properties` in the current working directory @@ -141,7 +141,7 @@ powerjoular-ringbuffer-path=/dev/shm/joularcorering #### CSV file -PowerJoular writes power data to a CSV file with `-f`, which adds a row every second, or with `-o`, which keeps only the latest row and carries no header. Joular Code - Java reads the last row each monitoring cycle and supports both. Use this when the ring buffer is unavailable; `-o` is the better of the two here, since the file stays one row long. +PowerJoular writes power data to a CSV file with `-f`, which adds a row every second, or with `-o`, which keeps only the latest row and carries no header. Joular Code for Java reads the last row each monitoring cycle and supports both. Use this when the ring buffer is unavailable; `-o` is the better of the two here, since the file stays one row long. The file holding the power of the whole system has five columns: @@ -180,7 +180,7 @@ methods-filtering-prefix=com.example,org.myapp ## :bar_chart: Generated Files -Joular Code - Java writes results into the directory configured by `results-path`. Two CSV files are produced and appended to during execution: +Joular Code for Java writes results into the directory configured by `results-path`. Two CSV files are produced and appended to during execution: | File | Contents | |---|---| @@ -235,15 +235,15 @@ Each power source decides when a cycle closes, because each one knows its own ca ## :information_source: Notes -- Joular Code - Java requires `com.sun.management.OperatingSystemMXBean` to measure process and system CPU load. This is available in all standard HotSpot JVMs (OpenJDK, Oracle JDK). Minimal or embedded JVMs that do not provide this class are not supported. -- Thread CPU time attribution requires `ThreadMXBean.isThreadCpuTimeSupported()` to return `true`. If it does not, Joular Code - Java will fail. +- Joular Code for Java requires `com.sun.management.OperatingSystemMXBean` to measure process and system CPU load. This is available in all standard HotSpot JVMs (OpenJDK, Oracle JDK). Minimal or embedded JVMs that do not provide this class are not supported. +- Thread CPU time attribution requires `ThreadMXBean.isThreadCpuTimeSupported()` to return `true`. If it does not, Joular Code for Java will fail. - The agent's own monitoring thread is excluded from all energy measurements. - Power values of `0.0` are suppressed in the output (rows with zero power are not written). - The `NO_COLOR` environment variable disables ANSI color output in the agent banner. ## :newspaper: License -Joular Code - Java is licensed under the GNU LGPL 3 license only (LGPL-3.0-only). +Joular Code for Java is licensed under the GNU LGPL 3 license only (LGPL-3.0-only). Copyright © 2025-2026, Adel Noureddine. All rights reserved. This program and the accompanying materials are made available under the terms of the [GNU Lesser General Public License v3.0 (LGPL-3.0-only)](https://www.gnu.org/licenses/lgpl-3.0.en.html) which accompanies this distribution. diff --git a/pom.xml b/pom.xml index 969b40e..96c46d1 100644 --- a/pom.xml +++ b/pom.xml @@ -86,7 +86,7 @@ [21,) - Joular Code - Java requires Java 21 or later. + Joular Code for Java requires Java 21 or later. diff --git a/src/main/java/org/noureddine/joular/joularcodejava/agent/Agent.java b/src/main/java/org/noureddine/joular/joularcodejava/agent/Agent.java index 0c95dff..b7ca421 100644 --- a/src/main/java/org/noureddine/joular/joularcodejava/agent/Agent.java +++ b/src/main/java/org/noureddine/joular/joularcodejava/agent/Agent.java @@ -28,7 +28,7 @@ import org.noureddine.joular.joularcodejava.agent.utils.AgentProperties; /** - * Main Joular Code - Java entry point. + * Main Joular Code for Java entry point. */ public class Agent { @@ -48,7 +48,7 @@ private static void printJoularCodeJavaBanner() { if (version == null || version.isEmpty()) { version = "unknown"; } - String welcomeMessage = "Joular Code - Java: version " + version; + String welcomeMessage = "Joular Code for Java: version " + version; boolean noColor = System.getenv("NO_COLOR") != null; if (noColor) { @@ -82,7 +82,7 @@ public static void agentmain(String args, Instrumentation inst) { private static void start() { if (!started.compareAndSet(false, true)) { logger.log(Level.WARNING, - "Joular Code - Java is already monitoring this JVM. Ignoring this attach."); + "Joular Code for Java is already monitoring this JVM. Ignoring this attach."); return; } if (!startMonitoring()) { @@ -111,7 +111,7 @@ private static boolean startMonitoring() { if (!(ManagementFactory.getThreadMXBean() instanceof com.sun.management.ThreadMXBean threadBean)) { logger.log(Level.SEVERE, - "Unsupported JVM: requires com.sun.management.ThreadMXBean to read every thread's CPU time in one call. Joular Code - Java will not start."); + "Unsupported JVM: requires com.sun.management.ThreadMXBean to read every thread's CPU time in one call. Joular Code for Java will not start."); return false; } if (threadBean.isThreadCpuTimeSupported()) { @@ -120,14 +120,14 @@ private static boolean startMonitoring() { } } else { logger.log(Level.SEVERE, - "Thread CPU time is not supported on this JVM. Joular Code - Java will not start."); + "Thread CPU time is not supported on this JVM. Joular Code for Java will not start."); return false; } OperatingSystemMXBean osBean = ManagementFactory.getOperatingSystemMXBean(); if (!(osBean instanceof com.sun.management.OperatingSystemMXBean sunOsBean)) { logger.log(Level.SEVERE, - "Unsupported JVM: requires com.sun.management.OperatingSystemMXBean for CPU-load metrics. Joular Code - Java will not start."); + "Unsupported JVM: requires com.sun.management.OperatingSystemMXBean for CPU-load metrics. Joular Code for Java will not start."); return false; } From 3aa9d24227f4e88be081ea6bb69485b281da81f4 Mon Sep 17 00:00:00 2001 From: Adel Noureddine Date: Wed, 30 Sep 2026 21:38:55 +0200 Subject: [PATCH 7/7] Refactoring and updates --- .gitattributes | 2 - .github/workflows/maven.yml | 13 +- .gitignore | 9 +- README.md | 138 +++-- joularcodejava.properties.example | 55 ++ pom.xml | 49 +- .../joular/joularcodejava/agent/Agent.java | 205 ++----- .../joular/joularcodejava/agent/Branch.java | 111 ++++ .../joular/joularcodejava/agent/Config.java | 122 ++++ .../joularcodejava/agent/LinuxRapl.java | 172 ++++++ .../joular/joularcodejava/agent/Monitor.java | 252 ++++++++ .../agent/MonitoringHandler.java | 542 ------------------ .../agent/PowerJoularRingBuffer.java | 172 ++++++ .../joularcodejava/agent/PowerModel.java | 137 +++++ .../joularcodejava/agent/PowerSource.java | 62 ++ .../joularcodejava/agent/ResultWriter.java | 130 +++++ .../joularcodejava/agent/ThreadSampler.java | 74 +++ .../joularcodejava/agent/VmPowerFile.java | 174 ++++++ .../agent/result/ResultWriter.java | 172 ------ .../agent/source/LinuxRaplPowerSource.java | 188 ------ .../agent/source/PowerJoularCsvSource.java | 241 -------- .../source/PowerJoularRingBufferSource.java | 267 --------- .../agent/source/PowerSource.java | 90 --- .../agent/source/PowerSourceFactory.java | 42 -- .../agent/utils/AgentProperties.java | 149 ----- src/main/resources/joularcodejava.properties | 51 -- .../agent/AgentEntryPointTest.java | 24 +- .../joularcodejava/agent/BranchTest.java | 125 ++++ .../joularcodejava/agent/ConfigTest.java | 126 ++++ .../joularcodejava/agent/LinuxRaplTest.java | 155 +++++ .../joularcodejava/agent/MonitorTest.java | 192 +++++++ .../agent/MonitoringHandlerTest.java | 445 -------------- .../agent/PowerJoularRingBufferTest.java | 231 ++++++++ .../joularcodejava/agent/PowerModelTest.java | 168 ++++++ .../joularcodejava/agent/PowerSourceTest.java | 90 +++ .../agent/ResultWriterTest.java | 163 ++++++ .../agent/ThreadSamplerTest.java | 53 ++ .../joularcodejava/agent/VmPowerFileTest.java | 160 ++++++ .../agent/result/ResultWriterTest.java | 352 ------------ .../source/LinuxRaplPowerSourceTest.java | 261 --------- .../source/PowerJoularCsvSourceTest.java | 300 ---------- .../PowerJoularRingBufferSourceTest.java | 348 ----------- .../agent/source/PowerSourceFactoryTest.java | 142 ----- .../agent/source/TestLogging.java | 38 -- .../agent/utils/AgentPropertiesTest.java | 284 --------- 45 files changed, 3064 insertions(+), 4212 deletions(-) create mode 100644 joularcodejava.properties.example create mode 100644 src/main/java/org/noureddine/joular/joularcodejava/agent/Branch.java create mode 100644 src/main/java/org/noureddine/joular/joularcodejava/agent/Config.java create mode 100644 src/main/java/org/noureddine/joular/joularcodejava/agent/LinuxRapl.java create mode 100644 src/main/java/org/noureddine/joular/joularcodejava/agent/Monitor.java delete mode 100644 src/main/java/org/noureddine/joular/joularcodejava/agent/MonitoringHandler.java create mode 100644 src/main/java/org/noureddine/joular/joularcodejava/agent/PowerJoularRingBuffer.java create mode 100644 src/main/java/org/noureddine/joular/joularcodejava/agent/PowerModel.java create mode 100644 src/main/java/org/noureddine/joular/joularcodejava/agent/PowerSource.java create mode 100644 src/main/java/org/noureddine/joular/joularcodejava/agent/ResultWriter.java create mode 100644 src/main/java/org/noureddine/joular/joularcodejava/agent/ThreadSampler.java create mode 100644 src/main/java/org/noureddine/joular/joularcodejava/agent/VmPowerFile.java delete mode 100644 src/main/java/org/noureddine/joular/joularcodejava/agent/result/ResultWriter.java delete mode 100644 src/main/java/org/noureddine/joular/joularcodejava/agent/source/LinuxRaplPowerSource.java delete mode 100644 src/main/java/org/noureddine/joular/joularcodejava/agent/source/PowerJoularCsvSource.java delete mode 100644 src/main/java/org/noureddine/joular/joularcodejava/agent/source/PowerJoularRingBufferSource.java delete mode 100644 src/main/java/org/noureddine/joular/joularcodejava/agent/source/PowerSource.java delete mode 100644 src/main/java/org/noureddine/joular/joularcodejava/agent/source/PowerSourceFactory.java delete mode 100644 src/main/java/org/noureddine/joular/joularcodejava/agent/utils/AgentProperties.java delete mode 100644 src/main/resources/joularcodejava.properties create mode 100644 src/test/java/org/noureddine/joular/joularcodejava/agent/BranchTest.java create mode 100644 src/test/java/org/noureddine/joular/joularcodejava/agent/ConfigTest.java create mode 100644 src/test/java/org/noureddine/joular/joularcodejava/agent/LinuxRaplTest.java create mode 100644 src/test/java/org/noureddine/joular/joularcodejava/agent/MonitorTest.java delete mode 100644 src/test/java/org/noureddine/joular/joularcodejava/agent/MonitoringHandlerTest.java create mode 100644 src/test/java/org/noureddine/joular/joularcodejava/agent/PowerJoularRingBufferTest.java create mode 100644 src/test/java/org/noureddine/joular/joularcodejava/agent/PowerModelTest.java create mode 100644 src/test/java/org/noureddine/joular/joularcodejava/agent/PowerSourceTest.java create mode 100644 src/test/java/org/noureddine/joular/joularcodejava/agent/ResultWriterTest.java create mode 100644 src/test/java/org/noureddine/joular/joularcodejava/agent/ThreadSamplerTest.java create mode 100644 src/test/java/org/noureddine/joular/joularcodejava/agent/VmPowerFileTest.java delete mode 100644 src/test/java/org/noureddine/joular/joularcodejava/agent/result/ResultWriterTest.java delete mode 100644 src/test/java/org/noureddine/joular/joularcodejava/agent/source/LinuxRaplPowerSourceTest.java delete mode 100644 src/test/java/org/noureddine/joular/joularcodejava/agent/source/PowerJoularCsvSourceTest.java delete mode 100644 src/test/java/org/noureddine/joular/joularcodejava/agent/source/PowerJoularRingBufferSourceTest.java delete mode 100644 src/test/java/org/noureddine/joular/joularcodejava/agent/source/PowerSourceFactoryTest.java delete mode 100644 src/test/java/org/noureddine/joular/joularcodejava/agent/source/TestLogging.java delete mode 100644 src/test/java/org/noureddine/joular/joularcodejava/agent/utils/AgentPropertiesTest.java diff --git a/.gitattributes b/.gitattributes index 45e1632..e03c9a1 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,6 +1,4 @@ # Keep all files to LF file endings * text=auto eol=lf -# Binary formats, never end-of-line converted. The PowerJoular ring buffer is a packed byte layout read field by field, so a .bin fixture must survive untouched. *.jar binary -*.bin binary diff --git a/.github/workflows/maven.yml b/.github/workflows/maven.yml index 9f2456f..76968e3 100644 --- a/.github/workflows/maven.yml +++ b/.github/workflows/maven.yml @@ -16,15 +16,16 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, macos-latest, windows-latest] + java: ['21', '25'] runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - - name: Set up JDK 21 - uses: actions/setup-java@v5 + - name: Set up JDK ${{ matrix.java }} + uses: actions/setup-java@v6 with: - java-version: '21' + java-version: ${{ matrix.java }} distribution: 'temurin' cache: maven @@ -35,12 +36,12 @@ jobs: if: always() uses: actions/upload-artifact@v7 with: - name: test-results-${{ matrix.os }} + name: test-results-${{ matrix.os }}-${{ matrix.java }} path: target/surefire-reports/ if-no-files-found: ignore - name: Upload JAR artifact - if: matrix.os == 'ubuntu-latest' + if: matrix.os == 'ubuntu-latest' && matrix.java == '21' uses: actions/upload-artifact@v7 with: name: joularcode-java diff --git a/.gitignore b/.gitignore index 7307a5a..033c45a 100644 --- a/.gitignore +++ b/.gitignore @@ -7,19 +7,14 @@ release.properties dependency-reduced-pom.xml buildNumber.properties .mvn/timing.properties -# https://maven.apache.org/wrapper/#usage-without-binary-jar .mvn/wrapper/maven-wrapper.jar -# Eclipse m2e generated files -# Eclipse Core .project -# JDT-specific (Eclipse Java Development Tools) .classpath -. .DS_Store .idea/ .zed/ .vscode/ joular-code-java-results/ -joularcodejava.properties -*.class \ No newline at end of file +/joularcodejava.properties +*.class diff --git a/README.md b/README.md index 3ff2a17..8f0eb65 100644 --- a/README.md +++ b/README.md @@ -10,36 +10,36 @@ This project is part of [Joular Code](https://github.com/joular/joularcode), and ## :rocket: Features - Monitor power consumption and energy of each method and execution branch at runtime -- Works as a Java agent — no source code instrumentation or modification needed +- Works as a Java agent, with no source code instrumentation or modification needed - Samples the JVM stack at high frequency (default: every 10 ms) and attributes energy every second - No runtime dependencies: the agent runs on the JDK alone -- Supports three power data source backends: - - Shared memory ring buffer (IPC): lowest latency, recommended - - CSV file: file-based polling - - Linux RAPL powercap PKG domain: direct hardware counter source +- Measures CPU power in three ways: + - Linux RAPL, read directly from powercap: nothing else to install + - [PowerJoular](https://github.com/joular/powerjoular)'s shared memory ring buffer: Windows, macOS, Raspberry Pi, with the measuring in a separate process + - Inside a virtual machine, using a shared power file between the host and the guest - Generates CSV files with per-method and per-branch power (Watts) and energy (Joules) -- Produces two output sets: one for all methods (including JDK internals), one filtered to your application packages +- Produces two output sets: one for all methods (including JDK methods), and one filtered and calculated to your application's methods - Configurable method filtering by package/class prefix to focus energy data on your code - Cross-platform: Windows, macOS, Linux, and Raspberry Pi ## :bulb: How It Works -Joular Code for Java runs as a Java instrumentation agent alongside your application. Every monitoring cycle (default: 1 second), it: +Joular Code for Java runs as a Java agent alongside your application. Every monitoring cycle (about 1 second), it: -1. **Samples the JVM stack**: every `stack-monitoring-sample-rate` milliseconds, it captures the stack trace of every `RUNNABLE` thread, building sample counts for each call branch. -2. **Measures thread CPU time**: it takes CPU-time snapshots at the start and end of each cycle using `ThreadMXBean`, computing how much CPU time each thread consumed during the window. -3. **Reads system power** from PowerJoular: the total CPU power in Watts for the current monitoring cycle. -4. **Scales to process power**: it estimates the JVM process's share of CPU power using `(processCpuLoad / systemCpuLoad) * totalCpuPower`. -5. **Attributes energy to methods**: each thread receives a fraction of process power proportional to its CPU time. Within each thread, power is further distributed to call branches proportionally to their sample counts. +1. **Samples the JVM stack**: every `stack-monitoring-sample-rate` milliseconds, it captures the stack trace of every `RUNNABLE` thread, counting how often each call branch is seen. +2. **Measures CPU time**: at the start and end of each cycle, it reads the CPU time of each thread and of the whole JVM. +3. **Reads the CPU power** of the machine over the cycle, from RAPL, PowerJoular, or the host of a virtual machine. +4. **Scales to process power**: the JVM's share of the CPU power is its own CPU load over the machine's: `cpuPower * processLoad / systemLoad`. +5. **Attributes energy to methods and execution branches**: each thread receives a fraction of process power proportional to its CPU time. Within each thread, power is further distributed to execution call branches proportionally to their sample counts. 6. **Writes results** to CSV files: both power (W) and energy (J = W × interval) are recorded per branch per cycle. ## :package: Compilation and Installation ### Requirements -- Java 21 or later +- Java 21 or later, to build and to run the application being monitored - Apache Maven 3.6 or later -- [PowerJoular](https://github.com/joular/powerjoular) 2.0.0 or later for the `ringbuffer` and `csv` power sources, or Linux powercap/RAPL access for `rapl` +- One way to measure the CPU (see [Power sources](#power-sources)): read access to Linux RAPL, [PowerJoular](https://github.com/joular/powerjoular) 2.0.0 or later, or, in a virtual machine, a host running PowerJoular (or another power monitoring tool) ### Build @@ -51,7 +51,7 @@ cd joularcode-java mvn clean install ``` -This produces a fat JAR at: +This produces a JAR at: ``` target/joularcodejava-.jar @@ -61,9 +61,7 @@ The agent has no runtime dependencies, so the JAR holds nothing but its own clas ## :bulb: Usage -Joular Code for Java attaches to your Java application as a Java agent, either on the command line with `-javaagent:` or to a JVM that is already running. Start [PowerJoular](https://github.com/joular/powerjoular) first when using `ringbuffer` or `csv`; on Linux, `rapl` can read CPU package power directly from the powercap interface. - -PowerJoular measures the hardware through the [Joular Core](https://github.com/joular/joularcore) library. +Joular Code for Java attaches to your Java application as a Java agent, either on the command line with `-javaagent:` or to an already running JVM. On Linux it reads the CPU power from RAPL directly, and elsewhere (including Linux), start [PowerJoular](https://github.com/joular/powerjoular) with `-r` option (to export power data to a ring buffer), see [Power sources](#power-sources). ### Basic usage @@ -87,17 +85,13 @@ vm.loadAgent("/path/to/joularcodejava-.jar"); vm.detach(); ``` -Monitoring covers the JVM from the moment it attaches, so whatever the application did before that is not in the results. The target JVM has to allow it, which recent versions only do when started with `-XX:+EnableDynamicAgentLoading`: - -```bash -java -XX:+EnableDynamicAgentLoading -jar yourApplication.jar -``` +Monitoring covers the JVM from the moment it attaches, so whatever the application did before that is not in the results. JDK 21 and later allow the attach by default but print a warning: start the target JVM with `-XX:+EnableDynamicAgentLoading` to silence it, or with `-XX:-EnableDynamicAgentLoading` to forbid it. Configuration is read when the agent attaches, so `-Djoularcodejava.properties=...` belongs on the target JVM's own command line. Attaching a second time is ignored, with a warning, rather than starting a second monitor. ### Specifying a custom configuration file -By default, Joular Code for Java looks for `joularcodejava.properties` in the current working directory. To specify a custom path: +By default, Joular Code for Java looks for `joularcodejava.properties` in the current working directory. To specify another path: ```bash java -Djoularcodejava.properties=/path/to/joularcodejava.properties -javaagent:joularcodejava-.jar -jar yourApplication.jar @@ -105,65 +99,75 @@ java -Djoularcodejava.properties=/path/to/joularcodejava.properties -javaagent:j ## :gear: Configuration -All configuration is done via a `joularcodejava.properties` file. Joular Code for Java loads it from the following locations in order: +All configuration is done via a `joularcodejava.properties` file, read from: + +1. The path given with the `-Djoularcodejava.properties=` JVM property +2. or `joularcodejava.properties` in the current working directory -1. Path specified by the `-Djoularcodejava.properties=` JVM property -2. `joularcodejava.properties` in the current working directory -3. Built-in defaults (bundled inside the JAR) +A missing file, or a property left empty, means the default value will be used. [joularcodejava.properties.example](joularcodejava.properties.example) is a commented example template you can use. ### Configuration properties | Property | Default | Description | |---|---|---| -| `power-source-type` | `ringbuffer` | Power data backend: `ringbuffer`, `csv`, or `rapl` | +| `power-source-type` | `auto` | Where the CPU power comes from: `auto`, `rapl`, `ringbuffer` or `vm` | | `powerjoular-ringbuffer-path` | OS-dependent | Path to the PowerJoular ring buffer (see below) | -| `powerjoular-csv-path` | `powerjoular-data.csv` | Path to the PowerJoular system CSV file | -| `stack-monitoring-sample-rate` | `10` | Stack sampling interval in milliseconds. Lower = more accurate but higher overhead | +| `vm-power-file` | *(empty)* | In a virtual machine, the file the host writes hte power of the guest virtual machine (see below) | +| `vm-power-format` | `powerjoular` | Format of `vm-power-file`: `powerjoular` or `watts` | +| `stack-monitoring-sample-rate` | `10` | Stack sampling interval in milliseconds, from 1 to 1000. Lower = more accurate but higher overhead | | `results-path` | `joular-code-java-results` | Directory where CSV result files are written | | `methods-filtering-prefix` | *(empty)* | Comma-separated list of package/class prefixes to filter app-specific methods (e.g., `com.example,org.myapp`) | -### Power source backends +### Power sources + +With `power-source-type=auto`, the default, the agent reads RAPL on Linux, and the PowerJoular ring buffer everywhere else, including Linux machines without RAPL such as the Raspberry Pi. On Linux, `ringbuffer` keeps the application unprivileged: run `sudo powerjoular -r` and set it explicitly. In a virtual machine, set `vm`. -#### Ring buffer (recommended) +#### Linux RAPL (`rapl`) + +Reads the energy counters of the CPU packages directly from `/sys/class/powercap/intel-rapl:N`, Intel and AMD alike, adding up every package of a machine with several sockets. Nothing else is needed, but `energy_uj` is only readable by root on most distributions: run the application as root, give its user read access to those files (for example with a udev rule), or run PowerJoular with `-r` as root and set `power-source-type=ringbuffer`. PowerJoular reads the first package only, so on a machine with several sockets `rapl` and `ringbuffer` do not give the same figure. + +```properties +power-source-type=rapl +``` -The ring buffer backend reads the shared memory area PowerJoular writes with `-r`. This is the lowest-latency option and has minimal overhead. The area is a plain file on every OS, mapped read-only by the agent. +#### PowerJoular ring buffer (`ringbuffer`) + +Reads the shared memory area [PowerJoular](https://github.com/joular/powerjoular) writes with `-r`. PowerJoular measures the hardware through [Joular Core](https://github.com/joular/joularcore) (RAPL on Windows, powermetrics on Macs, where PowerJoular has to run with `sudo`, power models on Raspberry Pi), and runs as a process of its own, so the privileges needed to measure the hardware stay out of the Java application. Each monitoring cycle ends as PowerJoular publishes a measurement, so the stack samples and the power describe the same second. PowerJoular may be started before or after the application, and restarted while it runs, except on Windows: a mapped file cannot be deleted there, so PowerJoular has to be started before the application and not restarted while it runs. Default paths by OS: -- **Linux**: `/dev/shm/joularcorering` -- **macOS**: `/tmp/joularcorering` -- **Windows**: `%PROGRAMDATA%\joularcorering` +- **Linux**: `/dev/shm/powerjoular` +- **macOS**: `/tmp/powerjoular` +- **Windows**: `%PROGRAMDATA%\powerjoular` ```properties power-source-type=ringbuffer -powerjoular-ringbuffer-path=/dev/shm/joularcorering +powerjoular-ringbuffer-path=/dev/shm/powerjoular ``` +#### Virtual machine (`vm`) -#### CSV file - -PowerJoular writes power data to a CSV file with `-f`, which adds a row every second, or with `-o`, which keeps only the latest row and carries no header. Joular Code for Java reads the last row each monitoring cycle and supports both. Use this when the ring buffer is unavailable; `-o` is the better of the two here, since the file stays one row long. +The CPU of a virtual machine cannot be measured from inside it, but its host can measure the process the virtual machine runs as. Run PowerJoular on the host for that process, share the file it writes with the virtual machine (virtiofs, 9p, a shared folder), and point the agent at it. Nothing else has to run in the virtual machine. -The file holding the power of the whole system has five columns: +On the host, `-p` with `-o` writes two files: `` for the whole host, and `-.csv` for the process, which is the one to share: -``` -Timestamp,CPU Usage,Total Power,CPU Power,GPU Power -1756681930,0.2460,18.4500,15.2000,3.2500 +```bash +powerjoular -p -o /shared/vm-power.csv ``` -The agent reads **CPU Power**, the fourth column. It recognises the file by how many columns its rows carry, so it reads both the form with a header and the headerless one `-o` writes. +In the virtual machine: ```properties -power-source-type=csv -powerjoular-csv-path=/path/to/powerjoular-data.csv +power-source-type=vm +vm-power-file=/mnt/shared/vm-power.csv-.csv +vm-power-format=powerjoular ``` -#### Linux RAPL powercap +Only the first line of the file is read, in one of the two formats: -Reads CPU package power directly from the Linux powercap RAPL PKG domain at `/sys/class/powercap/intel-rapl/intel-rapl:0`. This source is Linux-only, uses `energy_uj` and `max_energy_range_uj`, and supports the PKG `package-0` domain only. +- `powerjoular` (the default): the row PowerJoular rewrites every second with `-o` for a process, `timestamp,cpu_usage,cpu_power`. The file of the whole host, with five columns, is refused, since it would charge all of the host's power to one virtual machine. The host writes on its own one-second cycle, so a cycle may see the row the previous one saw, or catch the file while it is rewritten: the last row covers up to two more cycles, after which the host is taken to have stopped and no energy is attributed until it writes again. +- `watts`: the power alone, in watts, written by any tool. The value is used as it is, however long ago it was written. -```properties -power-source-type=rapl -``` +Some shared folders cache files in the guest (9p with `cache=loose`, virtiofs with `cache=always`), which can hide the host's updates: mount the share without caching. ### Method filtering @@ -177,19 +181,18 @@ methods-filtering-prefix=com.example,org.myapp - When set, `methods-power-app.csv` only contains branches where at least one stack element matches a prefix. Energy from unmatched (e.g., JDK) frames that are called by a matched method is attributed upward to the matched method. - `methods-power-all.csv` always contains every observed branch regardless of this setting. - ## :bar_chart: Generated Files Joular Code for Java writes results into the directory configured by `results-path`. Two CSV files are produced and appended to during execution: | File | Contents | |---|---| -| `methods-power-all.csv` | Power and energy for all observed call branches, including JDK internals | -| `methods-power-app.csv` | Power and energy for call branches filtered to your application packages | +| `methods-power-all.csv` | Power and energy for all observed call branches, including JDK ones | +| `methods-power-app.csv` | Power and energy for call branches filtered to your application's packages and methods | ### CSV format -Both files share the same schema: +Both files share the same format: ``` timestamp,branch,power_watts,energy_joules,interval_seconds,coverage @@ -219,33 +222,22 @@ timestamp,branch,power_watts,energy_joules,interval_seconds,coverage 1746000001000,com.example.Main.main;com.example.Worker.compute,2.158300000,2.158300000,1.000000000,0.9974 ``` -Each power source decides when a cycle closes, because each one knows its own cadence. With the ring buffer the agent closes a cycle the moment PowerJoular publishes a measurement, so the stack samples and the power describe the same second; if PowerJoular stops publishing, the agent says so once and falls back to a fixed one second cycle until it returns. The CSV and RAPL sources always use that fixed one second cycle: RAPL has no cycle of its own, and recognising the CSV's would mean re-parsing the file on every stack sample. - -## :warning: Troubleshooting - -- **"No fresh power data ... for 5 cycles"** (`WARNING` log): PowerJoular has stopped writing, or is writing somewhere else. Confirm it is still running, with `-r` for the ring buffer or `-f`/`-o` for CSV. A brief gap is covered by the last value read; past five cycles the agent reports `0.0` and attributes no energy. -- **"Could not open the PowerJoular ring buffer ..."** (`WARNING` log): the ring buffer memory area is not there yet. This is not fatal, the agent retries on every cycle, so starting the JVM before PowerJoular is fine. If it never clears, check `powerjoular-ringbuffer-path`. -- **"Could not read power data from ..."** (`WARNING` log): the configured `powerjoular-csv-path` does not exist. Check that PowerJoular is running with `-f` or `-o` and writing to the same path. The warning is logged only once until the file appears. -- **"The stack sampler cannot keep up ..."** (`WARNING` log): dumping the stacks of this many threads costs more than `stack-monitoring-sample-rate` allows, so fewer samples were taken than configured. The message gives the rate actually achieved. Raise the sample rate for an honest figure, or accept the coarser data; the energy totals stay correct either way, they are just based on fewer samples. -- **"Only N% of this JVM's CPU time belonged to threads that were sampled"** (`WARNING` log): see the `coverage` column above. The rows are a lower bound rather than wrong. -- **"... has published no new cycle for 2000 ms"** (`WARNING` log): PowerJoular's ring buffer counter has stopped moving, so the agent gave up aligning its cycles to it and went back to a fixed one second cycle. Results keep coming at the usual rate, but each cycle and the power charged to it may describe slightly different seconds. Check PowerJoular is still running with `-r`; the agent logs again at `INFO` when it starts publishing and the cycles realign. -- **"... is the file PowerJoular writes for one monitored process"** (`SEVERE` log): the CSV has three columns, so it holds the power of a single process, which the agent would scale a second time. Point `powerjoular-csv-path` at the file for the whole system instead. -- **RAPL mode fails at startup**: `power-source-type=rapl` is Linux-only and requires readable `/sys/class/powercap/intel-rapl/intel-rapl:0/name`, `energy_uj`, and `max_energy_range_uj` files for the `package-0` domain. Depending on the system, this may require elevated permissions. -- **No rows in `methods-power-app.csv`**: if `methods-filtering-prefix` is set, the configured prefix may not match any fully-qualified method name in your application. If it is unset, all observed methods are eligible for both output files. +With RAPL, a cycle lasts one second, and the energy is read at its very end, so the power and the stack samples cover the same time. In a virtual machine, a cycle lasts one second too, and gets the latest power the host wrote. With the ring buffer, a cycle ends when PowerJoular publishes a measurement, or after two seconds without one, in which case that cycle gets no rows. ## :information_source: Notes - Joular Code for Java requires `com.sun.management.OperatingSystemMXBean` to measure process and system CPU load. This is available in all standard HotSpot JVMs (OpenJDK, Oracle JDK). Minimal or embedded JVMs that do not provide this class are not supported. -- Thread CPU time attribution requires `ThreadMXBean.isThreadCpuTimeSupported()` to return `true`. If it does not, Joular Code for Java will fail. +- Thread CPU time attribution requires `ThreadMXBean.isThreadCpuTimeSupported()` to return `true`. If it does not, Joular Code for Java does not start, and the application runs unmonitored. +- The agent never follows a symbolic link when it opens its results files or the ring buffer, since it may run as root and these often sit in directories every user can write to. +- When the application runs as root, give the configuration file with `-Djoularcodejava.properties` rather than leaving it in a working directory others can write to, since it chooses where the results are written. - The agent's own monitoring thread is excluded from all energy measurements. - Power values of `0.0` are suppressed in the output (rows with zero power are not written). -- The `NO_COLOR` environment variable disables ANSI color output in the agent banner. ## :newspaper: License Joular Code for Java is licensed under the GNU LGPL 3 license only (LGPL-3.0-only). -Copyright © 2025-2026, Adel Noureddine. +Copyright © 2026, Adel Noureddine. All rights reserved. This program and the accompanying materials are made available under the terms of the [GNU Lesser General Public License v3.0 (LGPL-3.0-only)](https://www.gnu.org/licenses/lgpl-3.0.en.html) which accompanies this distribution. Author: Prof. Adel Noureddine diff --git a/joularcodejava.properties.example b/joularcodejava.properties.example new file mode 100644 index 0000000..a47b544 --- /dev/null +++ b/joularcodejava.properties.example @@ -0,0 +1,55 @@ +# Copyright (c) 2026, Adel Noureddine. +# All rights reserved. This program and the accompanying materials +# are made available under the terms of the +# GNU Lesser General Public License v3.0 (LGPL-3.0-only) +# which accompanies this distribution, and is available at +# https://www.gnu.org/licenses/lgpl-3.0.en.html +# +# Author : Adel Noureddine + +# Joular Code for Java: Configuration +# +# CPU power source +# Options: +# auto (RAPL on Linux, the PowerJoular ring buffer everywhere else, including Linux machines without RAPL such as the Raspberry Pi) +# rapl (read Linux RAPL directly, from /sys/class/powercap) +# ringbuffer (read the shared memory ring buffer PowerJoular writes with -r) +# vm (inside a virtual machine, read the file the host writes the power of this virtual machine to, see vm-power-file) +# Default if left empty: auto +power-source-type= + +# Path to the ring buffer (used by ringbuffer, and by auto on a machine without RAPL) +# Default if left empty (OS-dependent): +# Linux: /dev/shm/powerjoular +# macOS: /tmp/powerjoular +# Windows: %PROGRAMDATA%\powerjoular +powerjoular-ringbuffer-path= + +# The path to the file where the host writes the power consumption of the guest virtual machine (if power-source-type is vm), as seen from the virtual machine +# On the host, powerjoular -p -o writes it to -.csv +# Example: /mnt/shared/vm-power.csv-4242.csv +vm-power-file= + +# The format of the virtual machine file (if power-source-type is vm) +# Options: +# powerjoular (the row PowerJoular rewrites every second with -o for a process: timestamp,cpu_usage,cpu_power) +# watts (the power alone, in watts, used as it is) +# Default if left empty: powerjoular +vm-power-format= + +# The sample rate (milliseconds) for the agent to monitor methods usage by calling the JVM stack +# Higher count means less accurate energy data +# Lower count means higher overhead of the agent +# From 1 to 1000 +# Default if left empty: 10 (good balance between accuracy and efficiency) +stack-monitoring-sample-rate= + +# Output folder for results +# Default if left empty: joular-code-java-results +results-path= + +# Only frames whose package.class.method starts with one of these prefixes are filtered and calculated and written to methods-power-app.csv +# methods-power-all.csv always have every branch (including those of the JDK) +# Several prefixes are separated by a comma, e.g. com.example,org.myapp; any match counts +# Default if left empty: no filter (all branches go to both files) +methods-filtering-prefix= diff --git a/pom.xml b/pom.xml index 96c46d1..97625c7 100644 --- a/pom.xml +++ b/pom.xml @@ -22,7 +22,7 @@ jar ${project.artifactId} - Joular Code Java is a lightweight and efficient Java agent for monitoring the energy consumption of methods and executions branches + Joular Code for Java is a lightweight and efficient Java agent for monitoring the energy consumption of methods and execution branches 2026 https://github.com/joular/joularcode-java @@ -48,11 +48,7 @@ 21 - UTF-8 UTF-8 - 5.23.0 - - @@ -62,63 +58,20 @@ 6.1.3 test - - org.mockito - mockito-junit-jupiter - ${mockito.version} - test - - - org.apache.maven.plugins - maven-enforcer-plugin - 3.6.3 - - - enforce-java-version - - enforce - - - - - [21,) - Joular Code for Java requires Java 21 or later. - - - - - - org.apache.maven.plugins maven-compiler-plugin 3.16.0 - - - org.apache.maven.plugins - maven-dependency-plugin - 3.11.0 - - - resolve-dependency-paths - - properties - - - - org.apache.maven.plugins maven-surefire-plugin 3.6.0 - - @{argLine} -Xshare:off -javaagent:${org.mockito:mockito-core:jar} -Dnet.bytebuddy.experimental=true true diff --git a/src/main/java/org/noureddine/joular/joularcodejava/agent/Agent.java b/src/main/java/org/noureddine/joular/joularcodejava/agent/Agent.java index b7ca421..88be6e1 100644 --- a/src/main/java/org/noureddine/joular/joularcodejava/agent/Agent.java +++ b/src/main/java/org/noureddine/joular/joularcodejava/agent/Agent.java @@ -11,185 +11,100 @@ package org.noureddine.joular.joularcodejava.agent; -import java.lang.instrument.Instrumentation; -import java.lang.management.ManagementFactory; -import java.lang.management.OperatingSystemMXBean; import java.io.PrintWriter; import java.io.StringWriter; +import java.lang.instrument.Instrumentation; import java.util.Locale; -import java.util.concurrent.atomic.AtomicBoolean; +import java.util.Objects; +import java.util.logging.ConsoleHandler; import java.util.logging.Formatter; -import java.util.logging.Handler; import java.util.logging.Level; import java.util.logging.LogRecord; import java.util.logging.Logger; -import org.noureddine.joular.joularcodejava.agent.source.PowerSource; -import org.noureddine.joular.joularcodejava.agent.source.PowerSourceFactory; -import org.noureddine.joular.joularcodejava.agent.utils.AgentProperties; /** - * Main Joular Code for Java entry point. + * Entry point of Joular Code for Java, as {@code -javaagent:joularcodejava.jar} or loaded into a running JVM through the Attach API. + * Reads the configuration and starts the {@link Monitor}. */ -public class Agent { +public final class Agent { - public static final String AGENT_THREAD_NAME = "Joular-Agent-Monitor"; - private static final Logger logger = Logger.getLogger( - Agent.class.getName()); + private static final Logger logger = Logger.getLogger(Agent.class.getName()); - /** - * Whether the agent holds this JVM. The Attach API can be asked to load an agent as often as anyone likes, and a second monitoring thread would sample the same JVM twice and write both sets of rows into the same files. - */ - private static final AtomicBoolean started = new AtomicBoolean(false); - - private static void printJoularCodeJavaBanner() { - String version = Agent.class.getPackage() != null - ? Agent.class.getPackage().getImplementationVersion() - : null; - if (version == null || version.isEmpty()) { - version = "unknown"; - } - String welcomeMessage = "Joular Code for Java: version " + version; - boolean noColor = System.getenv("NO_COLOR") != null; + // Held in a field because java.util.logging only keeps weak references, and would lose the handler with the logger + private static final Logger joularLogger = Logger.getLogger("org.noureddine.joular"); - if (noColor) { - System.out.println(welcomeMessage); - return; - } + private static boolean loggingConfigured = false; - String boldYellow = "\u001B[1;33m"; - String reset = "\u001B[0m"; + /** The last monitor started, or null. Guarded by the class lock, since the Attach API can load the agent again. */ + private static Monitor activeMonitor; - System.out.println(boldYellow + welcomeMessage + reset); + private Agent() { } - /** Entry point when the agent is on the command line, as {@code -javaagent:joularcodejava.jar}. */ + /** Called when the agent is given on the command line. */ public static void premain(String args, Instrumentation inst) { start(); } - /** - * Entry point when the agent is loaded into a JVM that is already running, through the Attach - * API ({@code VirtualMachine.loadAgent}) or a tool built on it. - * - *

Monitoring covers the JVM from the moment it attaches, so whatever the application did - * before that is not in the results. Note that a JVM only accepts an agent this way when it was - * started with {@code -XX:+EnableDynamicAgentLoading}, or is old enough to still allow it by default. - */ + /** Called when the agent is loaded into a running JVM. Only what happens after that is measured. */ public static void agentmain(String args, Instrumentation inst) { start(); } - private static void start() { - if (!started.compareAndSet(false, true)) { - logger.log(Level.WARNING, - "Joular Code for Java is already monitoring this JVM. Ignoring this attach."); + private static synchronized void start() { + if (activeMonitor != null && activeMonitor.isAlive()) { + logger.log(Level.WARNING, "Joular Code for Java is already monitoring this JVM. Ignoring this attach."); return; } - if (!startMonitoring()) { - // Nothing was left running, so a later attach is free to try again - started.set(false); - } - } - - /** - * Brings the agent up. - * - * @return {@code true} when a monitoring thread was left running, {@code false} when startup could not proceed and this JVM is unchanged. - */ - private static boolean startMonitoring() { - configureLogging(); - printJoularCodeJavaBanner(); - logger.log(Level.INFO, "Initializing Joular Code Java..."); - - AgentProperties properties = new AgentProperties(); - PowerSource powerSource = PowerSourceFactory.getPowerSource(properties); - - if (powerSource == null) { - logger.log(Level.SEVERE, "No valid power source found. Agent will not start."); - return false; - } - - if (!(ManagementFactory.getThreadMXBean() instanceof com.sun.management.ThreadMXBean threadBean)) { + // Nothing may escape: an exception thrown out of premain stops the application from running at all + try { + configureLogging(); + printBanner(); + + Config config = Config.load(); + Monitor monitor = new Monitor(config, () -> PowerSource.open(config)); + monitor.start(); + Runtime.getRuntime().addShutdownHook(new Thread(monitor::stop, "Joular-Agent-Shutdown")); + activeMonitor = monitor; + logger.log(Level.INFO, "Joular Code for Java started. Results are written to " + config.resultsDir().toAbsolutePath()); + } catch (Throwable t) { logger.log(Level.SEVERE, - "Unsupported JVM: requires com.sun.management.ThreadMXBean to read every thread's CPU time in one call. Joular Code for Java will not start."); - return false; + "Joular Code for Java could not start. The application is unaffected and carries on without monitoring.", t); } - if (threadBean.isThreadCpuTimeSupported()) { - if (!threadBean.isThreadCpuTimeEnabled()) { - threadBean.setThreadCpuTimeEnabled(true); - } - } else { - logger.log(Level.SEVERE, - "Thread CPU time is not supported on this JVM. Joular Code for Java will not start."); - return false; - } - - OperatingSystemMXBean osBean = ManagementFactory.getOperatingSystemMXBean(); - if (!(osBean instanceof com.sun.management.OperatingSystemMXBean sunOsBean)) { - logger.log(Level.SEVERE, - "Unsupported JVM: requires com.sun.management.OperatingSystemMXBean for CPU-load metrics. Joular Code for Java will not start."); - return false; - } - - // The OS bean reports its loads over the interval since the previous call, so the first reading is meaningless and comes back negative. - // The monitoring loop's first window absorbs that: its share comes out as zero, so that window simply attributes nothing. - - MonitoringHandler monitoringHandler = new MonitoringHandler( - properties, - powerSource, - threadBean, - sunOsBean); - Thread monitoringThread = new Thread( - monitoringHandler, - AGENT_THREAD_NAME); - monitoringThread.setDaemon(true); - monitoringThread.start(); + } - boolean startupSignaled = monitoringHandler.awaitStartup(3000); - if (startupSignaled && !monitoringHandler.isStartupSuccessful()) { - Throwable startupFailure = monitoringHandler.getStartupFailure(); - logger.log(Level.SEVERE, "Monitoring thread failed during startup.", startupFailure); - return false; - } - if (!startupSignaled) { - logger.log(Level.WARNING, - "Monitoring startup could not be confirmed within 3 seconds. The monitoring thread may still be initializing."); + /** Sends the agent's own log records to stderr, without touching how the application logs. */ + private static void configureLogging() { + if (loggingConfigured) { + return; } + ConsoleHandler handler = new ConsoleHandler(); + handler.setFormatter(new LogFormatter()); + // Let everything through: the logger's level decides what is published + handler.setLevel(Level.ALL); + joularLogger.addHandler(handler); + joularLogger.setUseParentHandlers(false); + loggingConfigured = true; + } - Runtime.getRuntime().addShutdownHook( - new Thread(() -> { - logger.log(Level.INFO, "Stopping Joular Code Java..."); - monitoringHandler.stop(); - monitoringHandler.joinWithTimeout(2000); - })); - - logger.log(Level.INFO, "Joular Code Java started successfully."); - return true; + private static void printBanner() { + String version = Objects.requireNonNullElse(Agent.class.getPackage().getImplementationVersion(), "unknown"); + // stderr, like the logs: the application's stdout may be a data stream + System.err.println("Joular Code for Java: version " + version); } - private static void configureLogging() { - Logger root = Logger.getLogger(""); - for (Handler handler : root.getHandlers()) { - handler.setFormatter( - new Formatter() { - @Override - public String format(LogRecord record) { - StringBuilder formatted = new StringBuilder(String.format( - Locale.ROOT, - "%1$td-%1$tm-%1$tY %1$tH:%1$tM:%1$tS %2$s: %3$s%n", - record.getMillis(), - record.getLevel().getName(), - formatMessage(record))); - if (record.getThrown() != null) { - StringWriter sw = new StringWriter(); - PrintWriter pw = new PrintWriter(sw); - record.getThrown().printStackTrace(pw); - pw.flush(); - formatted.append(sw.toString()); - } - return formatted.toString(); - } - }); + /** {@code dd-MM-yyyy HH:mm:ss LEVEL: message}, followed by the stack trace if there is one. */ + private static final class LogFormatter extends Formatter { + @Override + public String format(LogRecord record) { + String line = String.format(Locale.ROOT, "%1$td-%1$tm-%1$tY %1$tH:%1$tM:%1$tS %2$s: %3$s%n", + record.getMillis(), record.getLevel().getName(), formatMessage(record)); + if (record.getThrown() == null) { + return line; + } + StringWriter stackTrace = new StringWriter(); + record.getThrown().printStackTrace(new PrintWriter(stackTrace)); + return line + stackTrace; } } } diff --git a/src/main/java/org/noureddine/joular/joularcodejava/agent/Branch.java b/src/main/java/org/noureddine/joular/joularcodejava/agent/Branch.java new file mode 100644 index 0000000..8854a6d --- /dev/null +++ b/src/main/java/org/noureddine/joular/joularcodejava/agent/Branch.java @@ -0,0 +1,111 @@ +/* + * Copyright (c) 2026, Adel Noureddine. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the + * GNU Lesser General Public License v3.0 (LGPL-3.0-only) + * which accompanies this distribution, and is available at + * https://www.gnu.org/licenses/lgpl-3.0.en.html + * + * Author : Adel Noureddine + */ + +package org.noureddine.joular.joularcodejava.agent; + +import java.util.List; + +/** + * A call branch: the stack of a thread, as the thread dump gives it (newest frame first). + * + *

Two stacks are the same branch when they go through the same methods, whatever the line numbers. + * Comparing frames is cheap next to building text, so the stacks are counted as they are, on every sample, and only written out as text once per window. + */ +final class Branch { + + private final StackTraceElement[] frames; + private final int hash; + + private Branch(StackTraceElement[] frames, int hash) { + this.frames = frames; + this.hash = hash; + } + + static Branch of(StackTraceElement[] frames) { + int hash = 1; + for (StackTraceElement frame : frames) { + hash = 31 * (31 * hash + frame.getClassName().hashCode()) + frame.getMethodName().hashCode(); + } + return new Branch(frames, hash); + } + + /** Every frame, oldest first, as {@code a.Main.main;a.Worker.compute}. */ + String name() { + return join(List.of()); + } + + /** Only the frames that start with one of the prefixes, oldest first; empty when none does. */ + String appName(List prefixes) { + return join(prefixes); + } + + private String join(List prefixes) { + StringBuilder name = new StringBuilder(); + for (int i = frames.length - 1; i >= 0; i--) { + StackTraceElement frame = frames[i]; + if (!prefixes.isEmpty() && !matchesAny(frame, prefixes)) { + continue; + } + if (!name.isEmpty()) { + name.append(';'); + } + name.append(frame.getClassName()).append('.').append(frame.getMethodName()); + } + return name.toString(); + } + + private static boolean matchesAny(StackTraceElement frame, List prefixes) { + for (String prefix : prefixes) { + if (startsWith(frame.getClassName(), frame.getMethodName(), prefix)) { + return true; + } + } + return false; + } + + /** + * Whether {@code className + "." + methodName} starts with {@code prefix}, without building that string. + * The prefix is plain text, not a package boundary: {@code com.example} also matches {@code com.examples}. + */ + static boolean startsWith(String className, String methodName, String prefix) { + int classLength = className.length(); + if (prefix.length() <= classLength) { + return className.startsWith(prefix); + } + return prefix.startsWith(className) + && prefix.charAt(classLength) == '.' + && methodName.regionMatches(0, prefix, classLength + 1, prefix.length() - classLength - 1); + } + + @Override + public boolean equals(Object other) { + if (!(other instanceof Branch that) || hash != that.hash || frames.length != that.frames.length) { + return false; + } + for (int i = 0; i < frames.length; i++) { + if (!frames[i].getClassName().equals(that.frames[i].getClassName()) + || !frames[i].getMethodName().equals(that.frames[i].getMethodName())) { + return false; + } + } + return true; + } + + @Override + public int hashCode() { + return hash; + } + + @Override + public String toString() { + return name(); + } +} diff --git a/src/main/java/org/noureddine/joular/joularcodejava/agent/Config.java b/src/main/java/org/noureddine/joular/joularcodejava/agent/Config.java new file mode 100644 index 0000000..dd04470 --- /dev/null +++ b/src/main/java/org/noureddine/joular/joularcodejava/agent/Config.java @@ -0,0 +1,122 @@ +/* + * Copyright (c) 2026, Adel Noureddine. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the + * GNU Lesser General Public License v3.0 (LGPL-3.0-only) + * which accompanies this distribution, and is available at + * https://www.gnu.org/licenses/lgpl-3.0.en.html + * + * Author : Adel Noureddine + */ + +package org.noureddine.joular.joularcodejava.agent; + +import java.io.IOException; +import java.io.Reader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.List; +import java.util.Locale; +import java.util.Properties; +import java.util.function.Predicate; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * The agent's configuration, read from {@code joularcodejava.properties}. + * + *

The file is the one named by the {@code joularcodejava.properties} system property, or else the one in the + * working directory. A missing file or an empty value means the default. + * + * @param powerSourceType {@code auto}, {@code rapl}, {@code ringbuffer} or {@code vm} + * @param ringBufferPath the ring buffer PowerJoular writes with {@code -r} + * @param vmPowerFile the file the host writes this virtual machine's power to, or null when not set + * @param vmPowerFormat {@code powerjoular} or {@code watts}, the format of {@code vmPowerFile} + * @param sampleRateMs how often the stacks are sampled, in milliseconds + * @param resultsDir where the CSV files are written + * @param appPrefixes what a frame must start with to be in the app file; empty means no filter + */ +record Config(String powerSourceType, Path ringBufferPath, Path vmPowerFile, String vmPowerFormat, + long sampleRateMs, Path resultsDir, List appPrefixes) { + + private static final Logger logger = Logger.getLogger(Config.class.getName()); + + static final long DEFAULT_SAMPLE_RATE_MS = 10; + private static final long LOW_SAMPLE_RATE_MS = 5; + // Samples past a window's length would stretch the window itself + private static final long MAX_SAMPLE_RATE_MS = 1000; + + static Config load() { + String configuredPath = System.getProperty("joularcodejava.properties", ""); + Path file = Path.of(configuredPath.isBlank() ? "joularcodejava.properties" : configuredPath); + + Properties properties = new Properties(); + if (!Files.isRegularFile(file)) { + logger.log(Level.INFO, "No configuration file at " + file.toAbsolutePath() + ", using defaults."); + } else { + try (Reader reader = Files.newBufferedReader(file, StandardCharsets.UTF_8)) { + properties.load(reader); + } catch (IOException e) { + logger.log(Level.WARNING, "Could not read " + file.toAbsolutePath() + ", using defaults.", e); + } + } + return from(properties); + } + + static Config from(Properties properties) { + String vmPowerFile = get(properties, "vm-power-file", ""); + return new Config( + get(properties, "power-source-type", "auto").toLowerCase(Locale.ROOT), + Path.of(get(properties, "powerjoular-ringbuffer-path", defaultRingBufferPath())), + vmPowerFile.isEmpty() ? null : Path.of(vmPowerFile), + get(properties, "vm-power-format", "powerjoular").toLowerCase(Locale.ROOT), + sampleRate(get(properties, "stack-monitoring-sample-rate", "")), + Path.of(get(properties, "results-path", "joular-code-java-results")), + prefixes(get(properties, "methods-filtering-prefix", ""))); + } + + private static String get(Properties properties, String key, String defaultValue) { + String value = properties.getProperty(key, "").trim(); + return value.isEmpty() ? defaultValue : value; + } + + private static long sampleRate(String value) { + if (value.isEmpty()) { + return DEFAULT_SAMPLE_RATE_MS; + } + try { + long rate = Long.parseLong(value); + if (rate > 0 && rate <= MAX_SAMPLE_RATE_MS) { + if (rate < LOW_SAMPLE_RATE_MS) { + logger.log(Level.WARNING, "stack-monitoring-sample-rate is " + rate + " ms. Below " + + LOW_SAMPLE_RATE_MS + " ms, sampling can noticeably slow the application down."); + } + return rate; + } + } catch (NumberFormatException ignored) { + // Reported below, like a value out of range + } + logger.log(Level.WARNING, "stack-monitoring-sample-rate must be a number of milliseconds from 1 to " + + MAX_SAMPLE_RATE_MS + ", not '" + value + "'. Using " + DEFAULT_SAMPLE_RATE_MS + " ms."); + return DEFAULT_SAMPLE_RATE_MS; + } + + private static List prefixes(String value) { + return Arrays.stream(value.split(",")) + .map(String::trim) + .filter(Predicate.not(String::isEmpty)) + .toList(); + } + + /** Where PowerJoular puts its ring buffer on each OS. */ + private static String defaultRingBufferPath() { + String os = System.getProperty("os.name", "").toLowerCase(Locale.ROOT); + if (os.contains("win")) { + String programData = System.getenv("PROGRAMDATA"); + return (programData == null || programData.isBlank() ? "C:\\ProgramData" : programData) + "\\powerjoular"; + } + return os.contains("mac") ? "/tmp/powerjoular" : "/dev/shm/powerjoular"; + } +} diff --git a/src/main/java/org/noureddine/joular/joularcodejava/agent/LinuxRapl.java b/src/main/java/org/noureddine/joular/joularcodejava/agent/LinuxRapl.java new file mode 100644 index 0000000..d710d5c --- /dev/null +++ b/src/main/java/org/noureddine/joular/joularcodejava/agent/LinuxRapl.java @@ -0,0 +1,172 @@ +/* + * Copyright (c) 2026, Adel Noureddine. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the + * GNU Lesser General Public License v3.0 (LGPL-3.0-only) + * which accompanies this distribution, and is available at + * https://www.gnu.org/licenses/lgpl-3.0.en.html + * + * Author : Adel Noureddine + */ + +package org.noureddine.joular.joularcodejava.agent; + +import java.io.IOException; +import java.nio.file.AccessDeniedException; +import java.nio.file.DirectoryStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Locale; +import java.util.function.LongSupplier; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * Reads the energy of the CPU packages straight from the Linux powercap RAPL interface. + * + *

Every package zone is summed, so a machine with several sockets is measured whole. + * A package zone is a {@code /sys/class/powercap/intel-rapl:N} whose {@code name} starts with {@code package}, on AMD processors too. + * Its sub-zones ({@code intel-rapl:N:M}: cores, uncore, DRAM) are left out, as is {@code psys}, which covers more than the CPU. + */ +final class LinuxRapl implements PowerSource { + + private static final Logger logger = Logger.getLogger(LinuxRapl.class.getName()); + + private static final Path POWERCAP = Path.of("/sys/class/powercap"); + + /** One package's energy counter, in microjoules. */ + private static final class Zone { + final Path energyFile; + final long maxEnergyUj; + long lastEnergyUj; + + Zone(Path energyFile, long maxEnergyUj) { + this.energyFile = energyFile; + this.maxEnergyUj = maxEnergyUj; + } + } + + private final List zones; + private final LongSupplier nanoTime; + private long lastReadNs; + + private LinuxRapl(List zones, LongSupplier nanoTime) { + this.zones = zones; + this.nanoTime = nanoTime; + this.lastReadNs = nanoTime.getAsLong(); + } + + /** Whether this is Linux with RAPL, readable or not. */ + static boolean isPresent() { + // Checked in this order: Path.of rejects the ':' of the zone name on Windows + return isLinux() && Files.isDirectory(POWERCAP.resolve("intel-rapl:0")); + } + + static LinuxRapl open() throws IOException { + if (!isLinux()) { + throw new IOException("power-source-type=rapl only works on Linux. Use auto or ringbuffer instead."); + } + return open(POWERCAP, System::nanoTime); + } + + static LinuxRapl open(Path powercapDir, LongSupplier nanoTime) throws IOException { + List zones; + try { + zones = packageZones(powercapDir); + for (Zone zone : zones) { + zone.lastEnergyUj = readEnergy(zone); + } + } catch (AccessDeniedException e) { + throw new IOException("Cannot read " + e.getFile() + ". RAPL is only readable by root on most Linux" + + " systems: run the application as root, give its user read access to the energy_uj files, or" + + " run PowerJoular with -r as root and set power-source-type=ringbuffer.", e); + } + logger.log(Level.INFO, "Reading CPU power from Linux RAPL: " + + zones.stream().map(zone -> zone.energyFile.getParent().toString()).toList()); + return new LinuxRapl(zones, nanoTime); + } + + private static List packageZones(Path powercapDir) throws IOException { + List zones = new ArrayList<>(); + try (DirectoryStream entries = Files.newDirectoryStream(powercapDir, "intel-rapl:*")) { + for (Path zoneDir : entries) { + boolean topLevel = zoneDir.getFileName().toString().matches("intel-rapl:\\d+"); + if (topLevel && Files.readString(zoneDir.resolve("name")).trim().startsWith("package")) { + zones.add(new Zone(zoneDir.resolve("energy_uj"), + parseMicrojoules(zoneDir.resolve("max_energy_range_uj")))); + } + } + } + if (zones.isEmpty()) { + throw new IOException("No RAPL package zone found in " + powercapDir + "."); + } + zones.sort(Comparator.comparing(zone -> zone.energyFile)); + return zones; + } + + @Override + public double watts() throws IOException { + // Every counter is read before any is updated, so a failure leaves them all where they were + long[] energyUj = new long[zones.size()]; + for (int i = 0; i < energyUj.length; i++) { + energyUj[i] = readEnergy(zones.get(i)); + } + long readNs = nanoTime.getAsLong(); + long elapsedNs = readNs - lastReadNs; + if (elapsedNs <= 0) { + throw new IOException("RAPL was read twice at the same instant."); + } + + long usedUj = 0; + for (int i = 0; i < energyUj.length; i++) { + Zone zone = zones.get(i); + usedUj += energyBetween(zone.lastEnergyUj, energyUj[i], zone.maxEnergyUj); + zone.lastEnergyUj = energyUj[i]; + } + lastReadNs = readNs; + return (usedUj / 1e6) / (elapsedNs / 1e9); + } + + @Override + public void close() { + } + + /** The energy used between two readings of a counter that wraps back to 0 past {@code maxUj}. */ + static long energyBetween(long beforeUj, long afterUj, long maxUj) { + if (afterUj >= beforeUj) { + return afterUj - beforeUj; + } + long wrapped = maxUj - beforeUj + afterUj; + // More than half the range in one window is not a wrap: the counter was reset, e.g. after suspend + return wrapped > maxUj / 2 ? 0 : wrapped; + } + + private static long readEnergy(Zone zone) throws IOException { + long energy = parseMicrojoules(zone.energyFile); + if (energy > zone.maxEnergyUj) { + throw new IOException("RAPL counter " + zone.energyFile + " is above its range: " + energy + " uJ."); + } + return energy; + } + + /** Reads a file holding a number of microjoules, which cannot be negative. */ + private static long parseMicrojoules(Path file) throws IOException { + String text = Files.readString(file).trim(); + try { + long value = Long.parseLong(text); + if (value >= 0) { + return value; + } + } catch (NumberFormatException ignored) { + // Reported below, like a negative value + } + throw new IOException("Unexpected value in " + file + ": '" + text + "'."); + } + + private static boolean isLinux() { + return System.getProperty("os.name", "").toLowerCase(Locale.ROOT).contains("linux"); + } +} diff --git a/src/main/java/org/noureddine/joular/joularcodejava/agent/Monitor.java b/src/main/java/org/noureddine/joular/joularcodejava/agent/Monitor.java new file mode 100644 index 0000000..c2c0606 --- /dev/null +++ b/src/main/java/org/noureddine/joular/joularcodejava/agent/Monitor.java @@ -0,0 +1,252 @@ +/* + * Copyright (c) 2026, Adel Noureddine. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the + * GNU Lesser General Public License v3.0 (LGPL-3.0-only) + * which accompanies this distribution, and is available at + * https://www.gnu.org/licenses/lgpl-3.0.en.html + * + * Author : Adel Noureddine + */ + +package org.noureddine.joular.joularcodejava.agent; + +import com.sun.management.OperatingSystemMXBean; +import com.sun.management.ThreadMXBean; +import java.io.IOException; +import java.lang.management.ManagementFactory; +import java.util.List; +import java.util.Map; +import java.util.concurrent.Callable; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.locks.LockSupport; +import java.util.logging.Level; +import java.util.logging.Logger; +import org.noureddine.joular.joularcodejava.agent.PowerModel.CpuUsage; +import org.noureddine.joular.joularcodejava.agent.ThreadSampler.Samples; + +/** + * The monitoring loop. It runs on its own daemon thread, one window at a time: sample the stacks until the power + * source ends the window, split that window's power between the branches seen, and append the results. + */ +final class Monitor { + + private static final Logger logger = Logger.getLogger(Monitor.class.getName()); + + static final String THREAD_NAME = "Joular-Agent-Monitor"; + + private static final double LOW_COVERAGE_THRESHOLD = 0.5; + private static final double HIGH_OVERRUN_THRESHOLD = 0.25; + private static final long STOP_TIMEOUT_MS = 2000; + + private final Callable powerSourceOpener; + private final long sampleRateNs; + private final List appPrefixes; + private final ResultWriter results; + private final OperatingSystemMXBean osBean; + private final ThreadSampler sampler; + private final Thread thread; + + private volatile boolean running = true; + private boolean powerLost = false; + private boolean windowErrorLogged = false; + private boolean overrunWarned = false; + private boolean lowCoverageWarned = false; + + /** + * Checks the JVM and opens the results files. The power source is opened later, on the monitor thread. + * + * @throws IllegalStateException if this JVM cannot report the CPU time of its threads and of itself + */ + Monitor(Config config, Callable powerSourceOpener) throws IOException { + if (!(ManagementFactory.getThreadMXBean() instanceof ThreadMXBean threadBean) + || !threadBean.isThreadCpuTimeSupported()) { + throw new IllegalStateException("Unsupported JVM: it cannot report the CPU time of its threads."); + } + if (!(ManagementFactory.getOperatingSystemMXBean() instanceof OperatingSystemMXBean os) + || os.getProcessCpuTime() < 0) { + throw new IllegalStateException("Unsupported JVM: it cannot report its own CPU time."); + } + threadBean.setThreadCpuTimeEnabled(true); + + this.powerSourceOpener = powerSourceOpener; + this.sampleRateNs = TimeUnit.MILLISECONDS.toNanos(config.sampleRateMs()); + this.appPrefixes = config.appPrefixes(); + this.osBean = os; + this.results = new ResultWriter(config.resultsDir()); + this.thread = new Thread(this::run, THREAD_NAME); + this.thread.setDaemon(true); + this.sampler = new ThreadSampler(threadBean, thread.threadId()); + } + + void start() { + thread.start(); + } + + boolean isAlive() { + return thread.isAlive(); + } + + /** Stops the loop and waits a little for it to close the source and the files. */ + void stop() { + running = false; + // Not an interrupt: it would close the results files if it came while they are written + LockSupport.unpark(thread); + try { + thread.join(STOP_TIMEOUT_MS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + + /** CPU times at one instant. */ + private record CpuSnapshot(long nanoTime, long processCpuNs, Map threadCpuNs) { + } + + private CpuSnapshot snapshot() { + // The monitor's own thread is taken out of the JVM's CPU time, as it is out of the threads' + return new CpuSnapshot(System.nanoTime(), osBean.getProcessCpuTime() - sampler.ownCpuTime(), + sampler.cpuTimes()); + } + + private void run() { + // Nothing may escape: the monitor must never disturb the application + try (PowerSource powerSource = powerSourceOpener.call()) { + monitor(powerSource); + } catch (IOException e) { + // The message says what to fix, a stack trace would only bury it + logger.log(Level.SEVERE, "Cannot measure the CPU power: " + e.getMessage() + + " Monitoring is off, the application carries on."); + } catch (Throwable t) { + logger.log(Level.SEVERE, "Monitoring stopped on an unexpected error, the application carries on.", t); + } finally { + results.close(); + } + } + + private void monitor(PowerSource powerSource) { + // getCpuLoad covers the time since its previous call, by anyone in this JVM, so the first one covers nothing + osBean.getCpuLoad(); + + // Each window starts where the previous one ended, so the time spent writing results is not lost + CpuSnapshot windowStart = snapshot(); + while (running) { + try { + windowStart = runWindow(powerSource, windowStart); + windowErrorLogged = false; + } catch (RuntimeException e) { + logger.log(windowErrorLogged ? Level.FINE : Level.SEVERE, "Error in a monitoring window, continuing.", e); + windowErrorLogged = true; + windowStart = snapshot(); + } + } + } + + /** Runs one window and returns the snapshot it ended on, which the next window starts from. */ + private CpuSnapshot runWindow(PowerSource powerSource, CpuSnapshot start) { + Samples samples = sampleWindow(powerSource, start.nanoTime()); + if (!running) { + // Cut short by shutdown: a fraction of a window is not worth a row + return start; + } + CpuSnapshot end = snapshot(); + // Read right after the snapshot, so the power and the CPU times cover the same window + double cpuPower = readPower(powerSource); + double systemLoad = osBean.getCpuLoad(); + writeResults(samples, start, end, cpuPower, systemLoad); + return end; + } + + /** Samples the stacks at the configured rate until the power source ends the window. */ + private Samples sampleWindow(PowerSource powerSource, long windowStartNs) { + Samples samples = new Samples(); + + // Samples are due at fixed times, so the time a thread dump takes does not stretch the interval + long nextSampleNs = System.nanoTime(); + while (running && !powerSource.windowOver(System.nanoTime() - windowStartNs)) { + sampler.sample(samples); + + nextSampleNs += sampleRateNs; + long waitNs = nextSampleNs - System.nanoTime(); + if (waitNs > 0) { + // Returns early on unpark, which is how stop() ends the wait + LockSupport.parkNanos(waitNs); + } else { + samples.missed++; + nextSampleNs = System.nanoTime(); + } + } + return samples; + } + + /** The CPU power over the window, or 0 when it could not be read: attributing nothing beats inventing energy. */ + private double readPower(PowerSource powerSource) { + try { + double watts = powerSource.watts(); + if (!Double.isFinite(watts) || watts < 0) { + throw new IOException("The power source reported " + watts + " W."); + } + if (powerLost) { + logger.log(Level.INFO, "The CPU power can be read again, energy is attributed again."); + powerLost = false; + } + return watts; + } catch (IOException e) { + if (!powerLost) { + logger.log(Level.WARNING, "Could not read the CPU power: " + e.getMessage() + + " No energy is attributed until it can be read again."); + powerLost = true; + } + return 0.0; + } + } + + private void writeResults(Samples samples, CpuSnapshot start, CpuSnapshot end, double cpuPower, + double systemLoad) { + long elapsedNs = end.nanoTime() - start.nanoTime(); + double intervalSeconds = elapsedNs / 1e9; + warnIfOverrunning(samples, intervalSeconds); + + CpuUsage usage = PowerModel.cpuUsage(samples.branches.keySet(), start.threadCpuNs(), end.threadCpuNs()); + warnIfLowCoverage(usage.coverage()); + + double processShare = PowerModel.processShare(end.processCpuNs() - start.processCpuNs(), elapsedNs, + Runtime.getRuntime().availableProcessors(), systemLoad); + Map threadPower = PowerModel.threadPower(usage, cpuPower * processShare); + + Map allPower = PowerModel.branchPower(samples.branches, threadPower, Branch::name); + Map appPower = appPrefixes.isEmpty() + ? allPower + : PowerModel.branchPower(samples.branches, threadPower, branch -> branch.appName(appPrefixes)); + + results.write(allPower, appPower, System.currentTimeMillis(), intervalSeconds, usage.coverage()); + } + + private void warnIfOverrunning(Samples samples, double intervalSeconds) { + logger.log(Level.FINE, () -> "Window: " + samples.taken + " samples over " + intervalSeconds + " s, " + + samples.missed + " overran the interval"); + + boolean overrunning = samples.taken > 0 && (double) samples.missed / samples.taken >= HIGH_OVERRUN_THRESHOLD; + if (overrunning && !overrunWarned) { + long achievedMs = Math.round(intervalSeconds * 1000 / samples.taken); + logger.log(Level.WARNING, "The stack sampler cannot keep up with stack-monitoring-sample-rate=" + + TimeUnit.NANOSECONDS.toMillis(sampleRateNs) + " ms: " + samples.taken + " samples were taken" + + " in the last window, about one every " + achievedMs + " ms. Dumping the stacks of this many" + + " threads costs more than the interval allows. Raise the sample rate, or accept coarser data."); + } + overrunWarned = overrunning; + } + + private void warnIfLowCoverage(double coverage) { + logger.log(Level.FINE, () -> "Window coverage: " + coverage); + + boolean low = coverage > 0 && coverage < LOW_COVERAGE_THRESHOLD; + if (low && !lowCoverageWarned) { + logger.log(Level.WARNING, "Only " + Math.round(coverage * 100) + "% of this JVM's CPU time belonged to" + + " threads that were sampled, so the power reported per method is a lower bound. Short-lived" + + " threads and virtual threads, which the JVM does not report here, are the usual causes;" + + " a sample rate too coarse for the number of threads is another."); + } + lowCoverageWarned = low; + } +} diff --git a/src/main/java/org/noureddine/joular/joularcodejava/agent/MonitoringHandler.java b/src/main/java/org/noureddine/joular/joularcodejava/agent/MonitoringHandler.java deleted file mode 100644 index 6b2c202..0000000 --- a/src/main/java/org/noureddine/joular/joularcodejava/agent/MonitoringHandler.java +++ /dev/null @@ -1,542 +0,0 @@ -/* - * Copyright (c) 2026, Adel Noureddine. - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the - * GNU Lesser General Public License v3.0 (LGPL-3.0-only) - * which accompanies this distribution, and is available at - * https://www.gnu.org/licenses/lgpl-3.0.en.html - * - * Author : Adel Noureddine - */ - -package org.noureddine.joular.joularcodejava.agent; - -import java.lang.management.ThreadInfo; -import java.util.HashMap; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.locks.LockSupport; -import java.util.logging.Level; -import java.util.logging.Logger; -import org.noureddine.joular.joularcodejava.agent.result.ResultWriter; -import org.noureddine.joular.joularcodejava.agent.source.PowerSource; -import org.noureddine.joular.joularcodejava.agent.utils.AgentProperties; -import com.sun.management.OperatingSystemMXBean; -import com.sun.management.ThreadMXBean; - -/** - * Samples the stacks of the running threads and divides the power drawn over a window between the call branches that were seen on them. - * - *

The model

- *
- *   threadPower(t) = processPower x cpuDelta(t) / totalCpuDelta
- *   branchPower(b) = threadPower(t) x samples(t, b) / totalSamples(t)
- * 
- * - *

Power is split between threads by the CPU time each one used, and within a thread between branches by how often each was seen on its stack. - * Splitting between threads by CPU time rather than by sample count is what keeps a thread that was blocked in native I/O from being charged for the time it spent waiting: Java reports such a thread as {@code RUNNABLE} even though it is not on a processor, so it is sampled, but its CPU time barely moves. - * - *

The denominator is every thread that used CPU, not only the threads that were sampled. - * Power drawn by a thread never caught in a sample is therefore left unattributed rather than shared out over the threads that were. - * - *

What this cannot see

- *
    - *
  • Threads that die inside a window. {@code getThreadCpuTime} returns -1 once a thread has gone, so a thread that finishes its work mid window is absent from the closing snapshot and drops out of both the numerator and the denominator. - * Coverage therefore reads optimistically high in an application built on short lived threads.
  • - *
  • Virtual threads. {@code dumpAllThreads} reports platform threads only.
  • - *
  • Where a thread is inside a window. A sample says which branch a thread was on, not whether it was on a processor at that instant, so within one thread the samples taken while it was blocked still dilute the branches that were genuinely running.
  • - *
- */ -public class MonitoringHandler implements Runnable { - - private static final Logger logger = Logger.getLogger(MonitoringHandler.class.getName()); - - private static final long NANOS_PER_MILLI = 1_000_000L; - private static final long NANOS_PER_SECOND = 1_000_000_000L; - private static final long LOW_SAMPLE_RATE_WARNING_MS = 5L; - - /** Below this share of the JVM's CPU time being accounted for, the results are worth a warning. */ - private static final double LOW_COVERAGE_THRESHOLD = 0.5; - - /** Report a sampler that cannot keep up once it has missed this share of a window's samples. */ - private static final double HIGH_OVERRUN_THRESHOLD = 0.25; - - private volatile boolean running = true; - private volatile Thread monitoringThread; - private final PowerSource powerSource; - private final ResultWriter resultWriter; - private final long sampleRateMs; - private final List methodsFilteringPrefixes; - private final ThreadMXBean threadBean; - private final OperatingSystemMXBean osBean; - private final CountDownLatch startupLatch = new CountDownLatch(1); - private volatile boolean startupSuccessful = false; - private volatile Throwable startupFailure; - - private boolean overrunWarningLogged = false; - private boolean lowCoverageWarningLogged = false; - - public MonitoringHandler(AgentProperties properties, PowerSource powerSource, ThreadMXBean threadBean, - OperatingSystemMXBean osBean) { - this.powerSource = powerSource; - this.resultWriter = new ResultWriter(properties.getResultsPath()); - this.sampleRateMs = Math.max(1L, properties.getSampleRateMs()); - this.methodsFilteringPrefixes = properties.getMethodsFilteringPrefixes(); - this.threadBean = threadBean; - this.osBean = osBean; - if (this.sampleRateMs < LOW_SAMPLE_RATE_WARNING_MS) { - logger.log(Level.WARNING, - () -> "Configured stack-monitoring-sample-rate is " + this.sampleRateMs - + " ms. Values below " + LOW_SAMPLE_RATE_WARNING_MS - + " ms can noticeably increase monitoring overhead."); - } - } - - public void stop() { - this.running = false; - Thread t = this.monitoringThread; - if (t != null) { - t.interrupt(); - } - } - - public void joinWithTimeout(long timeoutMs) { - Thread t = this.monitoringThread; - if (t != null) { - try { - t.join(timeoutMs); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - } - } - - /** - * Waits up to {@code timeoutMs} for the startup attempt to settle (succeed or fail). - * - *

Returns {@code true} as soon as the latch is released — which happens both on - * successful initialization and on a startup failure. To distinguish, the - * caller must consult {@link #isStartupSuccessful()} / {@link #getStartupFailure()}. - * - * @return {@code true} if startup completed (success or failure) within the timeout, - * {@code false} if the timeout elapsed first (or the wait was interrupted). - */ - public boolean awaitStartup(long timeoutMs) { - try { - return startupLatch.await(timeoutMs, TimeUnit.MILLISECONDS); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - return false; - } - } - - public boolean isStartupSuccessful() { - return startupSuccessful; - } - - public Throwable getStartupFailure() { - return startupFailure; - } - - @Override - public void run() { - logger.log(Level.INFO, "Monitoring loop started."); - this.monitoringThread = Thread.currentThread(); - long monitoringThreadId = Thread.currentThread().threadId(); - - try { - try { - resultWriter.verifyWritable(); - powerSource.initialize(); - startupSuccessful = true; - } catch (Exception e) { - startupFailure = e; - logger.log(Level.SEVERE, "Failed to initialize monitoring loop", e); - return; - } finally { - // Counted down before the first window, so that the application's own main is not held up while the operating system bean warms itself. - startupLatch.countDown(); - } - - while (running) { - try { - runOneWindow(monitoringThreadId); - } catch (Exception e) { - logger.log(Level.SEVERE, "Error during monitoring iteration, continuing.", e); - } - } - } catch (Exception e) { - logger.log(Level.SEVERE, "Error in monitoring loop", e); - } finally { - startupLatch.countDown(); - try { - powerSource.close(); - } catch (RuntimeException e) { - logger.log(Level.FINE, "Error closing power source", e); - } - resultWriter.close(); - } - } - - /** Samples for one monitoring window, then attributes that window's power and writes it out. */ - private void runOneWindow(long monitoringThreadId) { - long windowStartNs = System.nanoTime(); - Map windowStartCpuTime = snapshotThreadCpuTime(monitoringThreadId); - - SampleSet samples = new SampleSet(); - powerSource.beginWindow(); - - // An absolute schedule: each sample is due at a fixed offset from the start of the window, so the time the stack dump takes is absorbed into the interval rather than added on top of it. - // Sleeping for the sample rate after the dump instead, quietly turns a configured 10 ms into 10 ms plus the dump, halving the resolution on a JVM with many threads - long sampleRateNs = sampleRateMs * NANOS_PER_MILLI; - long nextSampleNs = windowStartNs; - int missedSamples = 0; - boolean windowRanItsCourse = false; - - while (running && !Thread.currentThread().isInterrupted()) { - long now = System.nanoTime(); - if (powerSource.isWindowComplete(now - windowStartNs)) { - windowRanItsCourse = true; - break; - } - - takeSample(samples, monitoringThreadId); - - nextSampleNs += sampleRateNs; - long parkNs = nextSampleNs - System.nanoTime(); - if (parkNs > 0) { - // parkNanos neither throws on interrupt nor clears the flag, so the loop condition above is what ends the window when stop() interrupts us - LockSupport.parkNanos(parkNs); - } else { - // The dump alone already overran the interval, so there is nothing left to wait for - missedSamples++; - nextSampleNs = System.nanoTime(); - } - } - - if (!windowRanItsCourse) { - // The agent is shutting down and this window was cut off part way through. - // Its handful of samples cover a fraction of a second of a JVM that is already winding down, so attributing them would put a row in the results that looks like a cycle and is not. - return; - } - - long windowEndNs = System.nanoTime(); - long nowMs = System.currentTimeMillis(); - double intervalSeconds = (double) (windowEndNs - windowStartNs) / NANOS_PER_SECOND; - if (intervalSeconds <= 0) { - return; - } - - reportSampling(missedSamples, samples.samplesTaken, intervalSeconds); - - Map windowEndCpuTime = snapshotThreadCpuTime(monitoringThreadId); - Attribution attribution = attribute(samples.totalPerThread, windowStartCpuTime, windowEndCpuTime); - reportCoverage(attribution.coverage()); - - double processPower = processPower(); - Map threadPower = calculateThreadPower( - attribution.sampledCpuDeltas(), attribution.totalCpuDeltaNs(), processPower); - - attributeAndSave(samples.allBranches, samples.totalPerThread, threadPower, - nowMs, intervalSeconds, attribution.coverage(), "methods-power-all.csv"); - attributeAndSave(samples.appBranches, samples.totalPerThread, threadPower, - nowMs, intervalSeconds, attribution.coverage(), "methods-power-app.csv"); - } - - /** One pass over every live thread, recording the branch each runnable one is on */ - private void takeSample(SampleSet samples, long monitoringThreadId) { - // dumpAllThreads avoids the Map wrapping that Thread.getAllStackTraces() builds on top of the same native call - ThreadInfo[] threadInfos = threadBean.dumpAllThreads(false, false); - samples.samplesTaken++; - - for (ThreadInfo info : threadInfos) { - if (info == null) { - continue; - } - long threadId = info.getThreadId(); - if (threadId == monitoringThreadId || info.getThreadState() != Thread.State.RUNNABLE) { - continue; - } - - StackTraceElement[] stack = info.getStackTrace(); - if (stack.length == 0) { - continue; - } - - samples.totalPerThread.merge(threadId, 1, Integer::sum); - - if (!methodsFilteringPrefixes.isEmpty()) { - StringBuilder app = new StringBuilder(estimatedBranchLength(stack)); - String all = buildBranch(stack, app); - if (!all.isEmpty()) { - samples.recordAll(threadId, all); - } - if (app.length() > 0) { - samples.recordApp(threadId, app.toString()); - } - } else { - // With no filter the two groupings hold the same branches, so the stack is walked once and the one string is shared between them. - String branch = buildBranch(stack); - if (!branch.isEmpty()) { - samples.recordAll(threadId, branch); - samples.recordApp(threadId, branch); - } - } - } - } - - /** - * Builds the branch, oldest frame first, as the viewer expects. - * - *

The class and the method go straight into the builder rather than through a joined {@code "class.method"} string. - * Only the filter needs that string, and this is the path taken when there is no filter: one throwaway string per frame, per thread, per sample adds up to everal hundred thousand allocations a second on a JVM with many threads. - */ - private static String buildBranch(StackTraceElement[] stack) { - // Presized: the default 16 characters would grow to a couple of kilobytes through about eight array copies for a stack of any depth - StringBuilder branch = new StringBuilder(estimatedBranchLength(stack)); - for (int i = stack.length - 1; i >= 0; i--) { - StackTraceElement element = stack[i]; - appendSeparator(branch); - branch.append(element.getClassName()).append('.').append(element.getMethodName()); - } - return branch.toString(); - } - - /** - * Builds the full branch and, in the same walk of the stack, the branch of just those frames passing the method filter. - * - *

The filter matches on the whole {@code "class.method"} name, so here that string has to be built. It is then reused for both branches rather than formed twice. - */ - private String buildBranch(StackTraceElement[] stack, StringBuilder filtered) { - StringBuilder all = new StringBuilder(estimatedBranchLength(stack)); - for (int i = stack.length - 1; i >= 0; i--) { - StackTraceElement element = stack[i]; - String methodName = element.getClassName() + "." + element.getMethodName(); - append(all, methodName); - if (matchesMethodFilter(methodName)) { - append(filtered, methodName); - } - } - return all.toString(); - } - - private static int estimatedBranchLength(StackTraceElement[] stack) { - return Math.max(64, stack.length * 48); - } - - private static void append(StringBuilder branch, String methodName) { - appendSeparator(branch); - branch.append(methodName); - } - - private static void appendSeparator(StringBuilder branch) { - if (branch.length() > 0) { - branch.append(';'); - } - } - - /** What was seen during one monitoring window. */ - private static final class SampleSet { - final Map> allBranches = new HashMap<>(); - final Map> appBranches = new HashMap<>(); - final Map totalPerThread = new HashMap<>(); - int samplesTaken = 0; - - void recordAll(long threadId, String branch) { - record(allBranches, threadId, branch); - } - - void recordApp(long threadId, String branch) { - record(appBranches, threadId, branch); - } - - private static void record(Map> branches, long threadId, String branch) { - branches.computeIfAbsent(threadId, k -> new HashMap<>()).merge(branch, 1, Integer::sum); - } - } - - /** How a monitoring window's CPU time divided between the threads that were sampled and the rest */ - record Attribution(Map sampledCpuDeltas, long totalCpuDeltaNs, double coverage) { - } - - /** - * Works out how much CPU each thread used over the monitoring window, and how much of that belonged to threads that were actually sampled. - */ - static Attribution attribute(Map sampledThreads, Map startCpuTime, - Map endCpuTime) { - Map sampledDeltas = new HashMap<>(); - long totalDeltaNs = 0; - long sampledDeltaNs = 0; - - for (Map.Entry entry : endCpuTime.entrySet()) { - long threadId = entry.getKey(); - // A thread missing from the opening snapshot was started after the window began, so every nanosecond on its counter was spent inside the window and all of it counts. - // Safe because a thread id is never reused. - long delta = entry.getValue() - startCpuTime.getOrDefault(threadId, 0L); - if (delta <= 0) { - continue; - } - totalDeltaNs += delta; - if (sampledThreads.containsKey(threadId)) { - sampledDeltaNs += delta; - sampledDeltas.put(threadId, delta); - } - } - - double coverage = totalDeltaNs > 0 ? (double) sampledDeltaNs / totalDeltaNs : 0.0; - return new Attribution(sampledDeltas, totalDeltaNs, coverage); - } - - private void reportSampling(int missedSamples, int samplesTaken, double intervalSeconds) { - logger.log(Level.FINE, () -> "Window: " + samplesTaken + " samples over " - + String.format(Locale.ROOT, "%.3f", intervalSeconds) + " s, " - + missedSamples + " overran the interval"); - - if (samplesTaken <= 0) { - return; - } - - if ((double) missedSamples / samplesTaken >= HIGH_OVERRUN_THRESHOLD) { - if (!overrunWarningLogged) { - long achievedMs = Math.round(intervalSeconds * 1000.0 / samplesTaken); - logger.log(Level.WARNING, - () -> "The stack sampler cannot keep up with stack-monitoring-sample-rate=" - + sampleRateMs + " ms: " + samplesTaken + " samples were taken in the last" - + " window, an effective rate of about " + achievedMs + " ms. Dumping the" - + " stacks of this many threads costs more than the interval allows. Raise" - + " the sample rate for an honest one, or accept the coarser data."); - overrunWarningLogged = true; - } - return; - } - // Back within budget, so a later spell of overrunning is worth reporting again - overrunWarningLogged = false; - } - - private void reportCoverage(double coverage) { - logger.log(Level.FINE, () -> "Window coverage: " + coverage); - if (coverage <= 0) { - return; - } - - if (coverage < LOW_COVERAGE_THRESHOLD) { - if (!lowCoverageWarningLogged) { - logger.log(Level.WARNING, - () -> "Only " + Math.round(coverage * 100) + "% of this JVM's CPU time belonged to" - + " threads that were sampled, so the power reported per method is a lower bound. Threads that are short lived, or virtual threads, which the JVM does not report here, are the usual causes. Another cause is a sample rate too coarse for the number of threads."); - lowCoverageWarningLogged = true; - } - return; - } - // Coverage recovered, so a later drop is worth reporting again - lowCoverageWarningLogged = false; - } - - /** The power this JVM is drawing: the machine's CPU power times this JVM's share of the work. */ - private double processPower() { - double totalCpuPower = powerSource.getCurrentPower(); - double processShare = estimateProcessShare(); - double power = totalCpuPower > 0 && processShare > 0 ? totalCpuPower * processShare : 0.0; - return Double.isFinite(power) && power > 0 ? power : 0.0; - } - - /** The share of the machine's busy CPU time that belongs to this JVM */ - private double estimateProcessShare() { - // The bean's first reading is negative, which sanitizeCpuLoad turns into zero, so the first window attributes nothing and warms the bean for the ones after it - double processCpuLoad = sanitizeCpuLoad(osBean.getProcessCpuLoad()); - double systemCpuLoad = sanitizeCpuLoad(osBean.getCpuLoad()); - if (processCpuLoad <= 0) { - return 0.0; - } - if (systemCpuLoad <= 0) { - // The system CPU load could not be read while the JVM's own CPU load could. - // Falling back to the JVM's load in order to keep the cycle rather than discarding it. - // This means less accurate data, as it amounts to taking the machine as fully loaded, which under-states the share of the power the JVM is given rather than over-stating it. - return processCpuLoad; - } - return sanitizeCpuLoad(processCpuLoad / systemCpuLoad); - } - - private boolean matchesMethodFilter(String methodName) { - for (String prefix : methodsFilteringPrefixes) { - if (methodName.startsWith(prefix)) { - return true; - } - } - return false; - } - - private double sanitizeCpuLoad(double value) { - if (!Double.isFinite(value) || value <= 0) { - return 0; - } - return Math.min(1.0, value); - } - - /** - * Every live thread's cumulative CPU time, the agent's own thread aside since its time is overhead rather than application work. - */ - private Map snapshotThreadCpuTime(long excludedThreadId) { - long[] ids = threadBean.getAllThreadIds(); - // One call for every thread, rather than one native transition per thread - long[] times = threadBean.getThreadCpuTime(ids); - - Map snapshot = new HashMap<>(Math.max(16, ids.length * 2)); - for (int i = 0; i < ids.length; i++) { - if (ids[i] != excludedThreadId && times[i] >= 0) { - snapshot.put(ids[i], times[i]); - } - } - return snapshot; - } - - /** - * Divides the JVM's power between the sampled threads. - * - *

{@code totalDeltaCpuTime} covers every thread that used CPU, including those never caught in a sample, so their share is simply not handed out rather than being spread over the threads that were seen. - */ - static Map calculateThreadPower(Map sampledCpuDeltas, double totalDeltaCpuTime, - double totalJHPower) { - Map threadPowerMap = new HashMap<>(); - if (totalJHPower <= 0 || !Double.isFinite(totalJHPower) || totalDeltaCpuTime <= 0) { - return threadPowerMap; - } - - for (Map.Entry entry : sampledCpuDeltas.entrySet()) { - long threadCpuTime = entry.getValue(); - if (threadCpuTime > 0) { - threadPowerMap.put(entry.getKey(), totalJHPower * (threadCpuTime / totalDeltaCpuTime)); - } - } - return threadPowerMap; - } - - private void attributeAndSave(Map> stats, Map totalThreadSamples, - Map threadPowerMap, long timestampMs, double intervalSeconds, double coverage, - String fileName) { - Map methodPowerTotals = new HashMap<>(); - - for (Map.Entry> threadEntry : stats.entrySet()) { - long threadId = threadEntry.getKey(); - Double threadPower = threadPowerMap.get(threadId); - if (threadPower == null || threadPower <= 0) - continue; - - Integer totalSamples = totalThreadSamples.get(threadId); - if (totalSamples == null || totalSamples <= 0) - continue; - - for (var methodEntry : threadEntry.getValue().entrySet()) { - double methodPower = (methodEntry.getValue().doubleValue() / totalSamples) * threadPower; - methodPowerTotals.merge(methodEntry.getKey(), methodPower, Double::sum); - } - } - - if (!methodPowerTotals.isEmpty()) { - resultWriter.writeRuntimeMethods(methodPowerTotals, timestampMs, intervalSeconds, coverage, fileName); - } - } -} diff --git a/src/main/java/org/noureddine/joular/joularcodejava/agent/PowerJoularRingBuffer.java b/src/main/java/org/noureddine/joular/joularcodejava/agent/PowerJoularRingBuffer.java new file mode 100644 index 0000000..eb510a6 --- /dev/null +++ b/src/main/java/org/noureddine/joular/joularcodejava/agent/PowerJoularRingBuffer.java @@ -0,0 +1,172 @@ +/* + * Copyright (c) 2026, Adel Noureddine. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the + * GNU Lesser General Public License v3.0 (LGPL-3.0-only) + * which accompanies this distribution, and is available at + * https://www.gnu.org/licenses/lgpl-3.0.en.html + * + * Author : Adel Noureddine + */ + +package org.noureddine.joular.joularcodejava.agent; + +import java.io.IOException; +import java.lang.invoke.VarHandle; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.channels.FileChannel; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.NoSuchFileException; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * Reads CPU power from the shared memory ring buffer PowerJoular writes with {@code -r}. + * PowerJoular runs as a process of its own, so the privileges needed to measure the hardware stay out of the Java application. + * + *

The area is a plain file on every OS, mapped read-only here. Layout, in the machine's byte order: + * + *

+ *   Offset 0  : u64 head, the number of cycles written so far
+ *   Offset 8  : entry[0], then entry[1] ... entry[4], 48 bytes each
+ *   Each entry:
+ *     +0  u64 timestamp      (Unix seconds)
+ *     +8  f64 cpu_power      (watts)  <- read here
+ *     +16 f64 gpu_power
+ *     +24 f64 total_power
+ *     +32 f64 cpu_usage
+ *     +40 f64 pid_app_power
+ *   Total: 8 + 5 * 48 = 248 bytes
+ * 
+ * + *

PowerJoular writes entry {@code head mod 5}, then increments {@code head}, once a second. + * Windows end when {@code head} moves, so the stack samples of a window describe the same second as the power charged to it. + * + *

When PowerJoular restarts, it deletes the file and creates a new one. The old one stays mapped here and stops + * moving, so a window without a new entry drops the mapping, and the next read maps whatever file is at the path by + * then. Not on Windows, which refuses to delete a mapped file: there PowerJoular has to be started before the + * application, and not restarted while it runs. A file left behind by a PowerJoular that is gone is told by the age + * of its newest entry. + */ +final class PowerJoularRingBuffer implements PowerSource { + + private static final Logger logger = Logger.getLogger(PowerJoularRingBuffer.class.getName()); + + static final int ENTRY_SIZE = 48; + static final int ENTRY_COUNT = 5; + static final int FILE_SIZE = 8 + ENTRY_COUNT * ENTRY_SIZE; + private static final int CPU_POWER_OFFSET = 8; + + /** How long a window waits for PowerJoular's next cycle. */ + static final long MAX_WINDOW_NS = 2 * ONE_SECOND_NS; + + /** PowerJoular writes once a second, so an entry older than this means it has stopped. */ + static final long MAX_AGE_SECONDS = 5; + + private final Path path; + + /** The mapped area, or null while there is none. */ + private ByteBuffer buffer; + + /** The head when the current window began. */ + private long windowStartHead; + + private PowerJoularRingBuffer(Path path) { + this.path = path; + } + + /** Opens the source even if the ring buffer is not there yet: PowerJoular may start after the application. */ + static PowerJoularRingBuffer open(Path path) { + PowerJoularRingBuffer ring = new PowerJoularRingBuffer(path); + try { + ring.attach(); + ring.windowStartHead = ring.buffer.getLong(0); + } catch (IOException ignored) { + // Reported by the first reading, which tries again + } + logger.log(Level.INFO, "Reading CPU power from the PowerJoular ring buffer " + path); + return ring; + } + + @Override + public boolean windowOver(long elapsedNs) { + if (buffer == null) { + return elapsedNs >= ONE_SECOND_NS; + } + return buffer.getLong(0) != windowStartHead || elapsedNs >= MAX_WINDOW_NS; + } + + @Override + public double watts() throws IOException { + if (buffer == null) { + attach(); + } + + long head = buffer.getLong(0); + long previousHead = windowStartHead; + windowStartHead = head; + if (head <= 0) { + throw new IOException("PowerJoular has not published a measurement in " + path + " yet."); + } + if (head == previousHead) { + // Nothing measured this window. PowerJoular may have stopped, or restarted on a new file, which the next reading maps. + buffer = null; + throw new IOException("PowerJoular published no measurement in " + path + " for " + + MAX_WINDOW_NS / ONE_SECOND_NS + " s. Is it still running with -r?"); + } + + // The entry is complete once the counter past it is published, and PowerJoular only writes that slot again four cycles later. + // The counter is still read again after the entry: if it moved that far, this thread was paused and the entry may have been rewritten under it. + // The fence pairs with PowerJoular's barrier before it publishes the counter; without it an ARM CPU may reorder the reads. + VarHandle.acquireFence(); + + int offset = 8 + (int) ((head - 1) % ENTRY_COUNT) * ENTRY_SIZE; + long timestamp = buffer.getLong(offset); + double power = buffer.getDouble(offset + CPU_POWER_OFFSET); + + VarHandle.acquireFence(); + if (buffer.getLong(0) != head) { + throw new IOException("PowerJoular was writing to " + path + " while it was read."); + } + + // A timestamp in the future is accepted: the clock may have stepped + long ageSeconds = System.currentTimeMillis() / 1000L - timestamp; + if (ageSeconds > MAX_AGE_SECONDS) { + buffer = null; + throw new IOException("The latest measurement in " + path + " is " + ageSeconds + + " s old. Is PowerJoular still running with -r?"); + } + // A negative or NaN value is no power. PowerJoular writes 0 here when it has no CPU source + if (!Double.isFinite(power) || power < 0) { + throw new IOException("PowerJoular could not measure the CPU power (it wrote " + power + ")."); + } + return power; + } + + @Override + public void close() { + // The mapping is released by the garbage collector + buffer = null; + } + + private void attach() throws IOException { + // Not following links, and only a plain file: the default path is in a directory every user can write to, + // and a pipe planted there would block the open until someone writes to it + if (Files.exists(path, LinkOption.NOFOLLOW_LINKS) && !Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException(path + " is not a regular file."); + } + try (FileChannel channel = FileChannel.open(path, StandardOpenOption.READ, LinkOption.NOFOLLOW_LINKS)) { + // Mapping past the end of the file would fault on access rather than fail here + if (channel.size() < FILE_SIZE) { + throw new IOException(path + " is too short to be a PowerJoular ring buffer, or is still being created."); + } + buffer = channel.map(FileChannel.MapMode.READ_ONLY, 0, FILE_SIZE).order(ByteOrder.nativeOrder()); + } catch (NoSuchFileException e) { + throw new IOException("There is no PowerJoular ring buffer at " + path + ". Is PowerJoular running with -r?", e); + } + } +} diff --git a/src/main/java/org/noureddine/joular/joularcodejava/agent/PowerModel.java b/src/main/java/org/noureddine/joular/joularcodejava/agent/PowerModel.java new file mode 100644 index 0000000..6239053 --- /dev/null +++ b/src/main/java/org/noureddine/joular/joularcodejava/agent/PowerModel.java @@ -0,0 +1,137 @@ +/* + * Copyright (c) 2026, Adel Noureddine. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the + * GNU Lesser General Public License v3.0 (LGPL-3.0-only) + * which accompanies this distribution, and is available at + * https://www.gnu.org/licenses/lgpl-3.0.en.html + * + * Author : Adel Noureddine + */ + +package org.noureddine.joular.joularcodejava.agent; + +import java.util.HashMap; +import java.util.Map; +import java.util.Set; +import java.util.function.Function; + +/** + * Splits the CPU power drawn over a window between the call branches seen on the JVM's threads. + * + *

+ *   processPower   = cpuPower x processShare
+ *   threadPower(t) = processPower x cpuTime(t) / totalCpuTime
+ *   branchPower(b) = threadPower(t) x samples(t, b) / samples(t)
+ * 
+ * + *

Threads are weighted by CPU time rather than by sample count, so a thread blocked in native I/O (which Java + * still reports as {@code RUNNABLE}) is not charged for waiting. + * + *

{@code totalCpuTime} includes threads that were never sampled. Their share is left unattributed rather than + * spread over the threads that were seen; {@link CpuUsage#coverage()} says how much that is. + * + *

The JVM's native threads (GC, JIT) are in {@code processPower} but in no Java thread's CPU time, so their + * share is spread over the Java threads in proportion to theirs. + * + *

Blind spots: threads that end inside a window (their CPU time is no longer reported), virtual threads + * (not in thread dumps), and whether a sampled thread was actually on a CPU at that instant. + */ +final class PowerModel { + + private PowerModel() { + } + + /** + * The JVM's share of the machine's CPU power: its own CPU load over the machine's. + * + * @param processCpuNs CPU time the whole JVM used over the window + * @param elapsedNs the window's length + * @param cpus the processors available to the JVM + * @param systemLoad the machine's CPU load over the window, from 0 to 1; 0, negative or NaN when unknown + * @return a share from 0 to 1 + */ + static double processShare(long processCpuNs, long elapsedNs, int cpus, double systemLoad) { + if (processCpuNs <= 0 || elapsedNs <= 0 || cpus <= 0) { + return 0.0; + } + double processLoad = Math.min(1.0, (double) processCpuNs / ((double) elapsedNs * cpus)); + if (!(systemLoad > 0)) { + // Take the machine as fully loaded: this under-states the JVM's share, never over-states it + return processLoad; + } + return Math.min(1.0, processLoad / Math.min(1.0, systemLoad)); + } + + /** + * CPU time used over a window. + * + * @param sampledThreadsNs CPU time of each thread that was sampled at least once + * @param totalNs CPU time of all threads, sampled or not + * @param coverage the sampled share of {@code totalNs}, from 0 to 1 + */ + record CpuUsage(Map sampledThreadsNs, long totalNs, double coverage) { + } + + /** Compares two CPU-time snapshots, keyed by thread id. */ + static CpuUsage cpuUsage(Set sampledThreads, Map start, Map end) { + Map sampledThreadsNs = new HashMap<>(); + long totalNs = 0; + long sampledNs = 0; + + for (Map.Entry entry : end.entrySet()) { + long threadId = entry.getKey(); + // A thread missing from the start snapshot began inside the window (thread ids are never reused) + long usedNs = entry.getValue() - start.getOrDefault(threadId, 0L); + if (usedNs <= 0) { + continue; + } + totalNs += usedNs; + if (sampledThreads.contains(threadId)) { + sampledNs += usedNs; + sampledThreadsNs.put(threadId, usedNs); + } + } + + double coverage = totalNs > 0 ? (double) sampledNs / totalNs : 0.0; + return new CpuUsage(sampledThreadsNs, totalNs, coverage); + } + + /** Each sampled thread's share of the process power, in watts. */ + static Map threadPower(CpuUsage usage, double processPower) { + Map threadPower = new HashMap<>(); + if (processPower <= 0 || usage.totalNs() <= 0) { + return threadPower; + } + usage.sampledThreadsNs().forEach((threadId, usedNs) -> + threadPower.put(threadId, processPower * usedNs / usage.totalNs())); + return threadPower; + } + + /** + * Spreads each thread's power over the branches seen on it, in proportion to how often each was seen. + * + * @param samples per thread, how many times each branch was seen + * @param threadPower per thread, its power in watts + * @param naming the name a branch is reported under; an empty name leaves its share unattributed + * @return the power of each branch name, summed over threads + */ + static Map branchPower(Map> samples, Map threadPower, + Function naming) { + Map branchPower = new HashMap<>(); + samples.forEach((threadId, branches) -> { + Double power = threadPower.get(threadId); + if (power == null) { + return; + } + int threadSamples = branches.values().stream().mapToInt(Integer::intValue).sum(); + branches.forEach((branch, count) -> { + String name = naming.apply(branch); + if (!name.isEmpty()) { + branchPower.merge(name, power * count / threadSamples, Double::sum); + } + }); + }); + return branchPower; + } +} diff --git a/src/main/java/org/noureddine/joular/joularcodejava/agent/PowerSource.java b/src/main/java/org/noureddine/joular/joularcodejava/agent/PowerSource.java new file mode 100644 index 0000000..d573ad9 --- /dev/null +++ b/src/main/java/org/noureddine/joular/joularcodejava/agent/PowerSource.java @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2026, Adel Noureddine. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the + * GNU Lesser General Public License v3.0 (LGPL-3.0-only) + * which accompanies this distribution, and is available at + * https://www.gnu.org/licenses/lgpl-3.0.en.html + * + * Author : Adel Noureddine + */ + +package org.noureddine.joular.joularcodejava.agent; + +import java.io.IOException; + +/** + * Where the agent gets the machine's CPU power from. + * A source is opened, read and closed on the monitor thread only. + * + *

The monitor calls {@link #watts()} once per window, as the window ends, so a window always runs from one call to the next. + */ +interface PowerSource extends AutoCloseable { + + /** Length of a window, for a source with no cadence of its own. */ + long ONE_SECOND_NS = 1_000_000_000L; + + /** + * The CPU's average power since the previous call, or since the source was opened. + * + * @return watts, finite and {@code >= 0} + * @throws IOException when nothing usable could be read, in which case the window gets no energy + */ + double watts() throws IOException; + + /** + * Whether the window should end now. Called on every stack sample, so it must be cheap. + * + * @param elapsedNs time since the window began + */ + default boolean windowOver(long elapsedNs) { + return elapsedNs >= ONE_SECOND_NS; + } + + @Override + void close(); + + /** + * Opens the source named by {@code power-source-type}. {@code auto} reads RAPL directly on Linux, and the + * PowerJoular ring buffer everywhere else, including Linux machines without RAPL such as the Raspberry Pi. + */ + static PowerSource open(Config config) throws IOException { + return switch (config.powerSourceType()) { + case "auto" -> LinuxRapl.isPresent() + ? LinuxRapl.open() + : PowerJoularRingBuffer.open(config.ringBufferPath()); + case "rapl" -> LinuxRapl.open(); + case "ringbuffer" -> PowerJoularRingBuffer.open(config.ringBufferPath()); + case "vm" -> VmPowerFile.open(config.vmPowerFile(), config.vmPowerFormat()); + default -> throw new IOException("Unknown power-source-type '" + config.powerSourceType() + "'. Use auto, rapl, ringbuffer or vm."); + }; + } +} diff --git a/src/main/java/org/noureddine/joular/joularcodejava/agent/ResultWriter.java b/src/main/java/org/noureddine/joular/joularcodejava/agent/ResultWriter.java new file mode 100644 index 0000000..16a4ea3 --- /dev/null +++ b/src/main/java/org/noureddine/joular/joularcodejava/agent/ResultWriter.java @@ -0,0 +1,130 @@ +/* + * Copyright (c) 2026, Adel Noureddine. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the + * GNU Lesser General Public License v3.0 (LGPL-3.0-only) + * which accompanies this distribution, and is available at + * https://www.gnu.org/licenses/lgpl-3.0.en.html + * + * Author : Adel Noureddine + */ + +package org.noureddine.joular.joularcodejava.agent; + +import java.io.BufferedWriter; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * Appends each window's power per branch to the two CSV files of the results directory: every branch, and the branches of the application only. + */ +final class ResultWriter implements AutoCloseable { + + private static final Logger logger = Logger.getLogger(ResultWriter.class.getName()); + + static final String ALL_METHODS_FILE = "methods-power-all.csv"; + static final String APP_METHODS_FILE = "methods-power-app.csv"; + static final String CSV_HEADER = "timestamp,branch,power_watts,energy_joules,interval_seconds,coverage"; + + private final Path allFile; + private final Path appFile; + private final Map writers = new HashMap<>(); + private boolean writeErrorLogged = false; + + /** Creates the results directory and opens both files, so a path that cannot be written to fails at startup. */ + ResultWriter(Path resultsDir) throws IOException { + Files.createDirectories(resultsDir); + allFile = resultsDir.resolve(ALL_METHODS_FILE); + appFile = resultsDir.resolve(APP_METHODS_FILE); + try { + writerFor(allFile); + writerFor(appFile); + } catch (IOException e) { + close(); + throw e; + } + } + + /** + * Appends one row per branch with a positive power, to each file. + * + * @param coverage the share of the JVM's CPU time that belonged to sampled threads; below 1.0 the rows are a lower bound + */ + void write(Map allPower, Map appPower, long timestampMs, double intervalSeconds, + double coverage) { + try { + append(allFile, allPower, timestampMs, intervalSeconds, coverage); + append(appFile, appPower, timestampMs, intervalSeconds, coverage); + writeErrorLogged = false; + } catch (IOException e) { + // Drop the writers so the next window opens the files again + close(); + if (!writeErrorLogged) { + logger.log(Level.SEVERE, "Could not write results to " + allFile.getParent(), e); + writeErrorLogged = true; + } + } + } + + /** Flushes and closes both files. Safe to call more than once. */ + @Override + public void close() { + for (BufferedWriter writer : writers.values()) { + try { + writer.close(); + } catch (IOException e) { + logger.log(Level.FINE, "Could not close a results file", e); + } + } + writers.clear(); + } + + private void append(Path file, Map branchPower, long timestampMs, double intervalSeconds, + double coverage) throws IOException { + if (branchPower.isEmpty()) { + return; + } + BufferedWriter writer = writerFor(file); + for (Map.Entry entry : branchPower.entrySet()) { + double power = entry.getValue(); + if (power > 0) { + writer.write(String.format(Locale.ROOT, "%d,%s,%.9f,%.9f,%.9f,%.4f%n", timestampMs, + csvEscape(entry.getKey()), power, power * intervalSeconds, intervalSeconds, coverage)); + } + } + writer.flush(); + } + + private BufferedWriter writerFor(Path file) throws IOException { + BufferedWriter writer = writers.get(file); + if (writer == null) { + // Not following links: the agent may run as root, and a link planted in a shared results directory + // would otherwise have it append to any file on the system + writer = Files.newBufferedWriter(file, StandardCharsets.UTF_8, StandardOpenOption.CREATE, + StandardOpenOption.APPEND, LinkOption.NOFOLLOW_LINKS); + writers.put(file, writer); + if (Files.size(file) == 0) { + writer.write(CSV_HEADER); + writer.newLine(); + writer.flush(); + } + } + return writer; + } + + static String csvEscape(String value) { + if (value.contains(",") || value.contains("\"") || value.contains("\n") || value.contains("\r")) { + return "\"" + value.replace("\"", "\"\"") + "\""; + } + return value; + } +} diff --git a/src/main/java/org/noureddine/joular/joularcodejava/agent/ThreadSampler.java b/src/main/java/org/noureddine/joular/joularcodejava/agent/ThreadSampler.java new file mode 100644 index 0000000..76dbf9c --- /dev/null +++ b/src/main/java/org/noureddine/joular/joularcodejava/agent/ThreadSampler.java @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2026, Adel Noureddine. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the + * GNU Lesser General Public License v3.0 (LGPL-3.0-only) + * which accompanies this distribution, and is available at + * https://www.gnu.org/licenses/lgpl-3.0.en.html + * + * Author : Adel Noureddine + */ + +package org.noureddine.joular.joularcodejava.agent; + +import com.sun.management.ThreadMXBean; +import java.lang.management.ThreadInfo; +import java.util.HashMap; +import java.util.Map; + +/** + * Reads the JVM's threads: which branch each running thread is on, and how much CPU time each has used. + * The agent's own thread is left out of both. + */ +final class ThreadSampler { + + private final ThreadMXBean threadBean; + private final long ownThreadId; + + ThreadSampler(ThreadMXBean threadBean, long ownThreadId) { + this.threadBean = threadBean; + this.ownThreadId = ownThreadId; + } + + /** What was seen during one window. */ + static final class Samples { + /** Per thread, how many times it was caught on each branch. */ + final Map> branches = new HashMap<>(); + int taken = 0; + int missed = 0; + } + + /** Records the branch each running thread is on. */ + void sample(Samples samples) { + samples.taken++; + for (ThreadInfo info : threadBean.dumpAllThreads(false, false)) { + if (info == null || info.getThreadId() == ownThreadId || info.getThreadState() != Thread.State.RUNNABLE) { + continue; + } + StackTraceElement[] stack = info.getStackTrace(); + if (stack.length > 0) { + samples.branches.computeIfAbsent(info.getThreadId(), id -> new HashMap<>()) + .merge(Branch.of(stack), 1, Integer::sum); + } + } + } + + /** The cumulative CPU time of every live thread, in nanoseconds. */ + Map cpuTimes() { + long[] ids = threadBean.getAllThreadIds(); + long[] times = threadBean.getThreadCpuTime(ids); + Map cpuTimes = new HashMap<>(); + for (int i = 0; i < ids.length; i++) { + // -1 means the thread ended since the ids were read + if (ids[i] != ownThreadId && times[i] >= 0) { + cpuTimes.put(ids[i], times[i]); + } + } + return cpuTimes; + } + + /** The cumulative CPU time of the agent's own thread, in nanoseconds. */ + long ownCpuTime() { + return threadBean.getThreadCpuTime(ownThreadId); + } +} diff --git a/src/main/java/org/noureddine/joular/joularcodejava/agent/VmPowerFile.java b/src/main/java/org/noureddine/joular/joularcodejava/agent/VmPowerFile.java new file mode 100644 index 0000000..85215dd --- /dev/null +++ b/src/main/java/org/noureddine/joular/joularcodejava/agent/VmPowerFile.java @@ -0,0 +1,174 @@ +/* + * Copyright (c) 2026, Adel Noureddine. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the + * GNU Lesser General Public License v3.0 (LGPL-3.0-only) + * which accompanies this distribution, and is available at + * https://www.gnu.org/licenses/lgpl-3.0.en.html + * + * Author : Adel Noureddine + */ + +package org.noureddine.joular.joularcodejava.agent; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.NoSuchFileException; +import java.nio.file.Path; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * Reads the power of this virtual machine from a file its host writes, shared with the guest (virtiofs, 9p, a shared folder). + * The CPU of a virtual machine cannot be measured from inside it, but the host can measure the process the virtual machine runs as. + * + *

Only the first line is read, in one of the two formats: + *

    + *
  • {@code powerjoular}: the file PowerJoular rewrites every second with {@code -o} for a monitored process, + * one row of {@code timestamp,cpu_usage,cpu_power} and no header
  • + *
  • {@code watts}: the power alone, in watts, written by anything. It is used as it is.
  • + *
+ * + *

The host writes automatically one-second cycle, which windows cannot follow through a shared folder, so a window may see the row the previous one saw, or catch the file while it is rewritten. + * A row keeps covering for up to {@link #MAX_REUSES} more windows; after that, the host is taken to have stopped. + * Its own timestamp tells, so the clocks of the host and the guest are never compared. + */ +final class VmPowerFile implements PowerSource { + + private static final Logger logger = Logger.getLogger(VmPowerFile.class.getName()); + + /** A power value never takes that many characters, so a longer line is not one. */ + private static final int MAX_LINE_LENGTH = 256; + + /** How many more windows the last row covers when the next one cannot be read. */ + static final int MAX_REUSES = 2; + + private final Path file; + private final boolean powerJoularFormat; + + /** The host's timestamp of the last row read, or -1 before the first one. */ + private long lastTimestamp = -1; + private double lastPower; + private int reuses = 0; + + private VmPowerFile(Path file, boolean powerJoularFormat) { + this.file = file; + this.powerJoularFormat = powerJoularFormat; + } + + /** Opens the source even if the file is not there yet: the host may start writing it after the application. */ + static VmPowerFile open(Path file, String format) throws IOException { + if (file == null) { + throw new IOException("power-source-type=vm needs vm-power-file, the file the host writes the power of" + + " this virtual machine to."); + } + if (!format.equals("powerjoular") && !format.equals("watts")) { + throw new IOException("Unknown vm-power-format '" + format + "'. Use powerjoular or watts."); + } + logger.log(Level.INFO, "Reading the power of this virtual machine from " + file + " (" + format + " format)"); + return new VmPowerFile(file, format.equals("powerjoular")); + } + + @Override + public double watts() throws IOException { + if (!powerJoularFormat) { + return parsePower(firstLine(false)); + } + + IOException problem; + try { + Row row = readRow(); + if (row.timestamp() != lastTimestamp) { + lastTimestamp = row.timestamp(); + lastPower = row.power(); + reuses = 0; + return lastPower; + } + problem = new IOException("The host has not updated " + file + " for " + (MAX_REUSES + 1) + + " windows. Is PowerJoular still running on the host?"); + } catch (IOException e) { + problem = e; + } + + // The last row covers a window that saw no new one, for a little while + if (lastTimestamp < 0 || reuses == MAX_REUSES) { + throw problem; + } + reuses++; + return lastPower; + } + + @Override + public void close() { + } + + /** One row of the file PowerJoular writes for a process: the host's Unix time, and the power in watts. */ + private record Row(long timestamp, double power) { + } + + private Row readRow() throws IOException { + String[] columns = firstLine(true).split(",", -1); + if (columns.length == 5) { + throw new IOException(file + " holds the power of the whole host. Point vm-power-file at the file" + + " PowerJoular writes for the process of this virtual machine: with -p -o , that is" + + " -.csv."); + } + if (columns.length != 3) { + throw new IOException(file + " does not hold the row PowerJoular writes with -o for a process" + + " (timestamp,cpu_usage,cpu_power)."); + } + return new Row(parseTimestamp(columns[0]), parsePower(columns[2])); + } + + /** + * The first line of the file, trimmed. + * + * @param complete whether the line must end with a newline: a row without one may have been cut short while the + * host was writing it, and a cut number still parses + */ + private String firstLine(boolean complete) throws IOException { + // A pipe or a device would block the open until someone writes to it; a missing file is reported below + if (Files.exists(file) && !Files.isRegularFile(file)) { + throw new IOException(file + " is not a regular file."); + } + byte[] start; + try (InputStream input = Files.newInputStream(file)) { + start = input.readNBytes(MAX_LINE_LENGTH + 1); + } catch (NoSuchFileException e) { + throw new IOException("There is no " + file + ". Is it shared with this virtual machine, and is the host" + + " writing it?", e); + } + String text = new String(start, StandardCharsets.UTF_8); + int end = text.indexOf('\n'); + if (end < 0 && (complete || start.length > MAX_LINE_LENGTH)) { + throw new IOException("The first line of " + file + " is incomplete or too long."); + } + return (end < 0 ? text : text.substring(0, end)).trim(); + } + + private long parseTimestamp(String text) throws IOException { + try { + return Long.parseLong(text.trim()); + } catch (NumberFormatException e) { + throw new IOException("The first column of " + file + " is not a Unix timestamp. On the host, write the" + + " file with -o, not -f: -f starts it with a header.", e); + } + } + + private double parsePower(String text) throws IOException { + double power; + try { + power = Double.parseDouble(text.trim()); + } catch (NumberFormatException e) { + throw new IOException(file + " does not hold a power in watts where one is expected.", e); + } + // PowerJoular writes -1 for a process it could not measure + if (!Double.isFinite(power) || power < 0) { + throw new IOException("The host could not measure the power of this virtual machine (" + file + + " holds " + power + ")."); + } + return power; + } +} diff --git a/src/main/java/org/noureddine/joular/joularcodejava/agent/result/ResultWriter.java b/src/main/java/org/noureddine/joular/joularcodejava/agent/result/ResultWriter.java deleted file mode 100644 index 0f0b1ba..0000000 --- a/src/main/java/org/noureddine/joular/joularcodejava/agent/result/ResultWriter.java +++ /dev/null @@ -1,172 +0,0 @@ -/* - * Copyright (c) 2026, Adel Noureddine. - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the - * GNU Lesser General Public License v3.0 (LGPL-3.0-only) - * which accompanies this distribution, and is available at - * https://www.gnu.org/licenses/lgpl-3.0.en.html - * - * Author : Adel Noureddine - */ - -package org.noureddine.joular.joularcodejava.agent.result; - -import java.io.BufferedWriter; -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.StandardOpenOption; -import java.util.HashMap; -import java.util.Locale; -import java.util.Map; -import java.util.logging.Level; -import java.util.logging.Logger; - -public class ResultWriter { - - private static final Logger logger = Logger.getLogger(ResultWriter.class.getName()); - private static final String CSV_HEADER = - "timestamp,branch,power_watts,energy_joules,interval_seconds,coverage"; - - private final Path resultsDir; - private final Map openWriters = new HashMap<>(); - private boolean anyWriteErrorLogged = false; - - public ResultWriter(String resultsPath) { - this.resultsDir = Path.of(resultsPath); - } - - /** - * Verifies that the results directory exists (creating it if necessary) and is writable. - * Throws IOException if the directory cannot be created or is not writable, so the - * agent fails fast at startup instead of silently dropping results. - */ - public void verifyWritable() throws IOException { - Files.createDirectories(resultsDir); - if (!Files.isDirectory(resultsDir)) { - throw new IOException("Results path is not a directory: " + resultsDir); - } - Path probe = resultsDir.resolve(".joular-write-probe"); - try { - Files.write(probe, new byte[0], - StandardOpenOption.CREATE, - StandardOpenOption.WRITE, - StandardOpenOption.TRUNCATE_EXISTING); - } finally { - try { - Files.deleteIfExists(probe); - } catch (IOException ignored) { - // probe cleanup is best-effort - } - } - } - - /** - * Writes one row per branch for a monitoring cycle. - * - * @param coverage the share of this JVM's CPU time that belonged to threads the agent actually sampled during the cycle. Below 1.0 the figures on these rows are a lower bound: the power of threads that were never sampled is left unattributed rather than shared out over the ones that were. - */ - public void writeRuntimeMethods( - Map methodPower, - long timestamp, - double intervalSeconds, - double coverage, - String fileName) { - Path path = resultsDir.resolve(fileName); - logger.log(Level.FINE, () -> "Writing " + methodPower.size() + " methods to " + path); - - try { - BufferedWriter writer = openWriters.get(fileName); - if (writer == null) { - boolean needsHeader = !Files.exists(path) || Files.size(path) == 0L; - writer = Files.newBufferedWriter(path, StandardCharsets.UTF_8, - StandardOpenOption.CREATE, StandardOpenOption.APPEND); - if (needsHeader) { - writer.write(CSV_HEADER); - writer.newLine(); - } - openWriters.put(fileName, writer); - } - - for (Map.Entry entry : methodPower.entrySet()) { - double powerWatts = entry.getValue(); - if (powerWatts > 0) { - double energyJoules = powerWatts * intervalSeconds; - writer.write(String.format( - Locale.US, - "%d,%s,%.9f,%.9f,%.9f,%.4f", - timestamp, - csvEscape(entry.getKey()), - powerWatts, - energyJoules, - intervalSeconds, - coverage)); - writer.newLine(); - } - } - writer.flush(); - anyWriteErrorLogged = false; - } catch (IOException e) { - // If the writer is in a bad state, drop it so the next call retries from scratch. - BufferedWriter stale = openWriters.remove(fileName); - if (stale != null) { - try { - stale.close(); - } catch (IOException ignored) { - // best-effort - } - } - if (!anyWriteErrorLogged) { - logger.log(Level.SEVERE, "Error writing runtime methods to " + path, e); - anyWriteErrorLogged = true; - } else { - logger.log(Level.FINE, () -> "Error writing runtime methods to " + path + ": " + e.getMessage()); - } - } - } - - /** - * Flush and close any open writers. Safe to call multiple times. - */ - public void close() { - for (Map.Entry entry : openWriters.entrySet()) { - try { - entry.getValue().flush(); - entry.getValue().close(); - } catch (IOException e) { - logger.log(Level.FINE, () -> "Error closing writer for " + entry.getKey() + ": " + e.getMessage()); - } - } - openWriters.clear(); - } - - static String csvEscape(String value) { - if (value == null) { - return ""; - } - boolean needsQuoting = false; - for (int i = 0; i < value.length(); i++) { - char c = value.charAt(i); - if (c == ',' || c == '"' || c == '\n' || c == '\r') { - needsQuoting = true; - break; - } - } - if (!needsQuoting) { - return value; - } - StringBuilder sb = new StringBuilder(value.length() + 2); - sb.append('"'); - for (int i = 0; i < value.length(); i++) { - char c = value.charAt(i); - if (c == '"') { - sb.append('"').append('"'); - } else { - sb.append(c); - } - } - sb.append('"'); - return sb.toString(); - } -} diff --git a/src/main/java/org/noureddine/joular/joularcodejava/agent/source/LinuxRaplPowerSource.java b/src/main/java/org/noureddine/joular/joularcodejava/agent/source/LinuxRaplPowerSource.java deleted file mode 100644 index b89248d..0000000 --- a/src/main/java/org/noureddine/joular/joularcodejava/agent/source/LinuxRaplPowerSource.java +++ /dev/null @@ -1,188 +0,0 @@ -/* - * Copyright (c) 2026, Adel Noureddine. - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the - * GNU Lesser General Public License v3.0 (LGPL-3.0-only) - * which accompanies this distribution, and is available at - * https://www.gnu.org/licenses/lgpl-3.0.en.html - * - * Author : Adel Noureddine - */ - -package org.noureddine.joular.joularcodejava.agent.source; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.time.Clock; -import java.time.Duration; -import java.time.Instant; -import java.util.Locale; -import java.util.Objects; -import java.util.logging.Level; -import java.util.logging.Logger; - -/** - * Reads CPU package power from the Linux powercap RAPL interface, without PowerJoular. - * - *

This source reads the hardware itself rather than following an external producer, so the bounded cover described on {@link PowerSource} does not apply to it. - */ -public class LinuxRaplPowerSource implements PowerSource { - - private static final Logger logger = Logger.getLogger(LinuxRaplPowerSource.class.getName()); - private static final String DEFAULT_PKG_PATH = "/sys/class/powercap/intel-rapl/intel-rapl:0"; - private static final String EXPECTED_PKG_NAME = "package-0"; - private static final double MICROJOULES_PER_JOULE = 1_000_000.0; - - private final Path packagePath; - private final String osName; - private final Clock clock; - - private Path energyPath; - private double maxEnergyRangeJoules; - private double lastEnergyJoules; - private Instant lastReadAt; - private double lastKnownPower = 0.0; - private boolean initialized = false; - - public LinuxRaplPowerSource() { - this(System.getProperty("os.name", "")); - } - - private LinuxRaplPowerSource(String osName) { - this(defaultPackagePath(osName), osName, Clock.systemUTC()); - } - - LinuxRaplPowerSource(Path packagePath, String osName, Clock clock) { - this.packagePath = Objects.requireNonNull(packagePath, "packagePath"); - this.osName = osName == null ? "" : osName; - this.clock = Objects.requireNonNull(clock, "clock"); - } - - @Override - public void initialize() throws Exception { - if (!isLinux()) { - throw new IllegalStateException("Linux RAPL power source is only supported on Linux."); - } - - Path namePath = packagePath.resolve("name"); - Path maxEnergyRangePath = packagePath.resolve("max_energy_range_uj"); - energyPath = packagePath.resolve("energy_uj"); - - requireReadableFile(namePath, "RAPL package name"); - requireReadableFile(energyPath, "RAPL package energy"); - requireReadableFile(maxEnergyRangePath, "RAPL package max energy range"); - - String packageName = Files.readString(namePath).trim(); - if (!EXPECTED_PKG_NAME.equals(packageName)) { - throw new IOException("Unsupported RAPL package domain '" + packageName - + "' at " + packagePath + "; expected " + EXPECTED_PKG_NAME + "."); - } - - maxEnergyRangeJoules = readJoules(maxEnergyRangePath); - if (!Double.isFinite(maxEnergyRangeJoules) || maxEnergyRangeJoules <= 0) { - throw new IOException("Invalid RAPL max energy range: " + maxEnergyRangeJoules + " J."); - } - - lastEnergyJoules = readEnergyJoules(); - lastReadAt = clock.instant(); - lastKnownPower = 0.0; - initialized = true; - - logger.log(Level.INFO, () -> "Initialized Linux RAPL PKG source: " + packagePath); - } - - @Override - public double getCurrentPower() { - if (!initialized) { - return 0.0; - } - - try { - double currentEnergyJoules = readEnergyJoules(); - Instant currentReadAt = clock.instant(); - double elapsedSeconds = elapsedSeconds(lastReadAt, currentReadAt); - if (elapsedSeconds <= 0 || !Double.isFinite(elapsedSeconds)) { - return lastKnownPower; - } - - double energyDelta = currentEnergyJoules >= lastEnergyJoules - ? currentEnergyJoules - lastEnergyJoules - : currentEnergyJoules - lastEnergyJoules + maxEnergyRangeJoules; - if (!Double.isFinite(energyDelta) || energyDelta < 0) { - return lastKnownPower; - } - - double power = energyDelta / elapsedSeconds; - if (!Double.isFinite(power) || power < 0) { - return lastKnownPower; - } - - lastEnergyJoules = currentEnergyJoules; - lastReadAt = currentReadAt; - lastKnownPower = power; - return power; - } catch (Exception e) { - logger.log(Level.FINE, "Could not read Linux RAPL power data", e); - return lastKnownPower; - } - } - - @Override - public void close() { - initialized = false; - energyPath = null; - maxEnergyRangeJoules = 0.0; - lastEnergyJoules = 0.0; - lastReadAt = null; - lastKnownPower = 0.0; - } - - private boolean isLinux() { - return isLinux(osName); - } - - private static boolean isLinux(String osName) { - return osName != null && osName.toLowerCase(Locale.ROOT).contains("linux"); - } - - private static Path defaultPackagePath(String osName) { - if (isLinux(osName)) { - return Path.of(DEFAULT_PKG_PATH); - } - return Path.of("."); - } - - private double readEnergyJoules() throws IOException { - double energyJoules = readJoules(energyPath); - if (!Double.isFinite(energyJoules) || energyJoules < 0 || energyJoules > maxEnergyRangeJoules) { - throw new IOException("Invalid RAPL energy reading: " + energyJoules + " J."); - } - return energyJoules; - } - - private static double readJoules(Path path) throws IOException { - String text = Files.readString(path).trim(); - double microjoules; - try { - microjoules = Double.parseDouble(text); - } catch (NumberFormatException e) { - throw new IOException("Could not parse RAPL value from " + path + ": " + text, e); - } - return microjoules / MICROJOULES_PER_JOULE; - } - - private static void requireReadableFile(Path path, String description) throws IOException { - if (!Files.isRegularFile(path) || !Files.isReadable(path)) { - throw new IOException(description + " file is not readable: " + path); - } - } - - private static double elapsedSeconds(Instant start, Instant end) { - if (start == null || end == null) { - return 0.0; - } - Duration elapsed = Duration.between(start, end); - return elapsed.toNanos() / 1_000_000_000.0; - } -} diff --git a/src/main/java/org/noureddine/joular/joularcodejava/agent/source/PowerJoularCsvSource.java b/src/main/java/org/noureddine/joular/joularcodejava/agent/source/PowerJoularCsvSource.java deleted file mode 100644 index 59683ab..0000000 --- a/src/main/java/org/noureddine/joular/joularcodejava/agent/source/PowerJoularCsvSource.java +++ /dev/null @@ -1,241 +0,0 @@ -/* - * Copyright (c) 2026, Adel Noureddine. - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the - * GNU Lesser General Public License v3.0 (LGPL-3.0-only) - * which accompanies this distribution, and is available at - * https://www.gnu.org/licenses/lgpl-3.0.en.html - * - * Author : Adel Noureddine - */ - -package org.noureddine.joular.joularcodejava.agent.source; - -import java.io.IOException; -import java.io.RandomAccessFile; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.logging.Level; -import java.util.logging.Logger; - -/** - * Reads CPU power from the system CSV file PowerJoular writes with {@code -f} or {@code -o}. - * - *

The system file holds five columns: - * - *

- *   Timestamp,CPU Usage,Total Power,CPU Power,GPU Power
- *   1756681930,0.2460,18.4500,15.2000,3.2500
- * 
- * - *

{@code -f} adds a row every second and starts the file with the header. {@code -o} rewrites the file every second with the latest row only and no header, which is the better option here. Both are supported: the file is recognised by how many columns its rows carry, so the header is only ever skipped over and never has to be parsed. - * - *

Monitoring a process makes PowerJoular write a second, three column file holding the power of that process alone. That file is rejected here. - * - *

PowerJoular writes here once a second, so this source does have a cadence of its own, but it keeps the default fixed window all the same: recognising the edge would mean parsing the file on every sample tick, and {@link PowerSource#isWindowComplete(long)} has to stay cheap. The agent's windows and the rows therefore drift by up to a second relative to each other, which is the price of reading this format. The ring buffer publishes a counter that can be read without parsing, so it aligns properly. - */ -public class PowerJoularCsvSource implements PowerSource { - - private static final Logger logger = Logger.getLogger(PowerJoularCsvSource.class.getName()); - - // How much of the end of the file is read to find the last row - private static final int TAIL_READ_SIZE = 8192; - - // Number of columns in the system file, and in the file of a monitored process - static final int SYSTEM_COLUMNS = 5; - static final int TARGET_COLUMNS = 3; - - // Where CPU Power sits in the system file - static final int SYSTEM_CPU_POWER_INDEX = 3; - - /** - * How old a row may be before it is treated as unreadable. PowerJoular writes once a second, so anything beyond this means it is no longer running. - */ - static final long MAX_ROW_AGE_SECONDS = 10; - - private final Path csvPath; - - private double lastKnownPower = 0.0; - private int staleCycles = 0; - private boolean missingFileWarningLogged = false; - private boolean targetFileErrorLogged = false; - - public PowerJoularCsvSource(String csvPath) { - this.csvPath = Path.of(csvPath); - } - - @Override - public void initialize() { - logger.log(Level.INFO, () -> "Initializing PowerJoular CSV source: " + csvPath); - } - - @Override - public double getCurrentPower() { - Double power = readPower(); - - if (power != null) { - lastKnownPower = power; - staleCycles = 0; - return power; - } - - staleCycles++; - if (staleCycles <= MAX_STALE_CYCLES) { - // In overwrite mode PowerJoular empties the file before writing the new row, so a read can land on nothing at all - return lastKnownPower; - } - - if (staleCycles == MAX_STALE_CYCLES + 1) { - logger.log(Level.WARNING, - () -> "No fresh power data in " + csvPath + " for " + MAX_STALE_CYCLES - + " cycles. Is PowerJoular still running? No energy will be attributed" - + " until it comes back."); - } - // Reporting zero stops the agent attributing energy from a value that is no longer true - return 0.0; - } - - /** - * Reads CPU Power from the last row of the file. - * - * @return the power in watts, or {@code null} when this cycle could not be read. - */ - private Double readPower() { - if (!Files.isRegularFile(csvPath) || !Files.isReadable(csvPath)) { - if (!missingFileWarningLogged) { - logger.log(Level.WARNING, - () -> "Could not read power data from " + csvPath - + ". Ensure PowerJoular is running and writing this file with -f or -o."); - missingFileWarningLogged = true; - } - return null; - } - missingFileWarningLogged = false; - - try { - String lastRow = readLastDataRow(); - if (lastRow == null) { - return null; - } - - String[] columns = lastRow.split(",", -1); - Double timestamp = parseTimestamp(columns[0]); - if (timestamp == null || isStale(timestamp)) { - return null; - } - - return parseCpuPower(columns, cpuPowerIndex(columns.length)); - } catch (IOException e) { - logger.log(Level.FINE, () -> "Could not read the latest row from " + csvPath); - return null; - } - } - - /** - * Check the power index in the CSV file, allowing us to know if we're using the whole system file, or the monitored process file - * - * @return the column index, or -1 when the row cannot be used. - */ - private int cpuPowerIndex(int columns) { - if (columns == SYSTEM_COLUMNS) { - return SYSTEM_CPU_POWER_INDEX; - } - - if (columns == TARGET_COLUMNS) { - if (!targetFileErrorLogged) { - logger.log(Level.SEVERE, - () -> csvPath + " has " + TARGET_COLUMNS + " columns, so it is the file" - + " PowerJoular writes for one monitored process, whose power is already the power of that process alone. Joular Code needs the power of the whole system: point this at the system file instead."); - targetFileErrorLogged = true; - } - return -1; - } - - logger.log(Level.FINE, () -> "Unexpected column count " + columns + " in " + csvPath); - return -1; - } - - /** - * The first column of a row, which PowerJoular writes as a Unix time in seconds - * - * @return the time, or {@code null} when the column holds no number, which is what marks the header row and any comment - */ - static Double parseTimestamp(String firstColumn) { - try { - return Double.parseDouble(firstColumn.trim()); - } catch (NumberFormatException e) { - return null; - } - } - - static Double parseCpuPower(String[] columns, int columnIndex) { - if (columnIndex < 0 || columns.length <= columnIndex) { - return null; - } - - String powerText = columns[columnIndex].trim(); - if (powerText.isEmpty()) { - return null; - } - - try { - double value = Double.parseDouble(powerText); - // PowerJoular writes -1 for a value it could not read at all. - if (!Double.isFinite(value) || value < 0) { - return null; - } - return value; - } catch (NumberFormatException e) { - logger.log(Level.FINE, () -> "Could not parse power value: " + powerText); - return null; - } - } - - /** - * Reads the last row of the file that carries data, skipping the header and any blank line. - */ - private String readLastDataRow() throws IOException { - try (RandomAccessFile file = new RandomAccessFile(csvPath.toFile(), "r")) { - long length = file.length(); - if (length <= 0) { - // In overwrite mode the file is emptied before the new row is written. - return null; - } - - int bytesToRead = (int) Math.min(TAIL_READ_SIZE, length); - long start = length - bytesToRead; - file.seek(start); - - byte[] data = new byte[bytesToRead]; - file.readFully(data); - - String[] lines = new String(data, StandardCharsets.UTF_8).split("\\r?\\n"); - // Reading from part way into the file almost certainly lands mid-row, so the first slice is skipped: a truncated row can still parse, as a wrong but valid number - int firstSafeIndex = (start > 0) ? 1 : 0; - - for (int i = lines.length - 1; i >= firstSafeIndex; i--) { - String line = lines[i].trim(); - // A data row opens with a time; anything else is the header, a comment or a blank. - if (!line.isEmpty() && parseTimestamp(firstColumnOf(line)) != null) { - return line; - } - } - return null; - } - } - - private static String firstColumnOf(String line) { - int comma = line.indexOf(','); - return comma == -1 ? line : line.substring(0, comma); - } - - // Whether the row is older than we are willing to use - private static boolean isStale(double timestamp) { - // A timestamp ahead of us is left alone: the clock may have stepped, and that is not staleness - return System.currentTimeMillis() / 1000L - timestamp > MAX_ROW_AGE_SECONDS; - } - - @Override - public void close() {} -} diff --git a/src/main/java/org/noureddine/joular/joularcodejava/agent/source/PowerJoularRingBufferSource.java b/src/main/java/org/noureddine/joular/joularcodejava/agent/source/PowerJoularRingBufferSource.java deleted file mode 100644 index e2c2a5e..0000000 --- a/src/main/java/org/noureddine/joular/joularcodejava/agent/source/PowerJoularRingBufferSource.java +++ /dev/null @@ -1,267 +0,0 @@ -/* - * Copyright (c) 2026, Adel Noureddine. - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the - * GNU Lesser General Public License v3.0 (LGPL-3.0-only) - * which accompanies this distribution, and is available at - * https://www.gnu.org/licenses/lgpl-3.0.en.html - * - * Author : Adel Noureddine - */ - -package org.noureddine.joular.joularcodejava.agent.source; - -import java.io.IOException; -import java.io.RandomAccessFile; -import java.lang.invoke.VarHandle; -import java.nio.ByteBuffer; -import java.nio.ByteOrder; -import java.nio.channels.FileChannel; -import java.util.logging.Level; -import java.util.logging.Logger; - -/** - * Reads CPU power from the shared memory ring buffer written by PowerJoular's {@code -r} option. - * - *

The area is a plain file on every OS, mapped read-only here. Its layout, in the byte order of the machine, is an 8-byte counter followed by 5 entries of 48 bytes: - * - *

- *   Offset 0      : u64  head (number of cycles written so far)
- *   Offset 8      : entry[0]  (48 bytes)
- *   Offset 56     : entry[1]
- *   ...
- *   Each entry:
- *     +0  u64  timestamp     (Unix time in seconds)
- *     +8  f64  cpu_power     (watts)   <- the field we read
- *     +16 f64  gpu_power     (watts)
- *     +24 f64  total_power   (watts)
- *     +32 f64  cpu_usage     (0.0 to 1.0)
- *     +40 f64  pid_app_power (watts, -1 when the monitored process could not be read)
- *
- *   Total size: 8 + 5 * 48 = 248 bytes.
- * 
- * - *

PowerJoular writes a cycle into the entry the counter points at ({@code head mod 5}) and raises the counter afterwards. This class follows the counter to know when a new cycle has landed, and the entry timestamp to know how old it is. - * - *

The area deliberately outlives PowerJoular on every OS, so a stale file always looks plausible. Freshness is therefore decided by the entry timestamp, not by the counter. - */ -public class PowerJoularRingBufferSource implements PowerSource { - - private static final Logger logger = Logger.getLogger(PowerJoularRingBufferSource.class.getName()); - - // Entry size in bytes: one u64 timestamp plus five f64 values - static final int ENTRY_SIZE = 48; - - // Number of entries in the ring - static final int BUFFER_SIZE = 5; - - // Total area size: the 8-byte head counter followed by the entries - static final int FILE_SIZE = 8 + BUFFER_SIZE * ENTRY_SIZE; - - // Offset of cpu_power within an entry, just past the timestamp - private static final int CPU_POWER_OFFSET = 8; - - /** - * How old an entry may be before it is treated as unreadable. PowerJoular writes once a second, so anything beyond this means it is no longer running. - */ - static final long MAX_ENTRY_AGE_SECONDS = 10; - - /** - * The longest a window waits for the next cycle before giving up on aligning to PowerJoular. - * It writes once a second, so twice that is already well past due. - */ - static final long MAX_WINDOW_NANOS = 2_000_000_000L; - - private final String path; - - private volatile ByteBuffer buffer; - private double lastKnownPower = 0.0; - private int staleCycles = 0; - private boolean attachWarningLogged = false; - - /** The counter as it stood when the current window opened, or -1 when there was nothing to follow. */ - private long windowOpeningCycle = -1; - - /** Set once the producer has missed its cycle, so windows stop waiting on a counter that is not moving. */ - private boolean producerStalled = false; - - public PowerJoularRingBufferSource(String path) { - this.path = path; - } - - @Override - public void initialize() { - logger.log(Level.INFO, () -> "Initializing PowerJoular ring buffer source: " + path); - // A missing area is not fatal: PowerJoular may not have started yet, so attaching is retried on every read instead of aborting the agent here. - attach(); - } - - @Override - public double getCurrentPower() { - Double power = readPower(); - - if (power != null) { - lastKnownPower = power; - staleCycles = 0; - return power; - } - - staleCycles++; - if (staleCycles <= MAX_STALE_CYCLES) { - // Cover a momentary hiccup with the last value we trust - return lastKnownPower; - } - - if (staleCycles == MAX_STALE_CYCLES + 1) { - logger.log(Level.WARNING, - () -> "No fresh power data in the PowerJoular ring buffer " + path + " for " - + MAX_STALE_CYCLES + " cycles. Is PowerJoular still running with -r?" - + " No energy will be attributed until it comes back."); - } - // Reporting zero stops the agent attributing energy from a value that is no longer true - return 0.0; - } - - @Override - public void beginWindow() { - windowOpeningCycle = headCounter(); - } - - /** - * Closes the window on the edge where PowerJoular publishes its next cycle, so the stack samples in the window describe the same second as the power charged to them. - * - *

If that edge does not arrive within {@link #MAX_WINDOW_NANOS} the producer has stopped, and waiting on it would stretch every window from then on. - * The source says so once and falls back to the plain fixed window until the counter moves again, so a dead PowerJoular costs the alignment rather than the cadence. - */ - @Override - public boolean isWindowComplete(long elapsedNs) { - if (windowOpeningCycle <= 0) { - // Not attached, or PowerJoular has yet to write its first cycle: there is no edge to follow - return elapsedNs >= DEFAULT_WINDOW_NANOS; - } - - if (headCounter() != windowOpeningCycle) { - markProducerPublishing(); - return true; - } - - if (producerStalled) { - return elapsedNs >= DEFAULT_WINDOW_NANOS; - } - - if (elapsedNs >= MAX_WINDOW_NANOS) { - markProducerStalled(); - return true; - } - return false; - } - - /** PowerJoular raises the counter once it has written a cycle, so a change is a published measurement. */ - private long headCounter() { - ByteBuffer area = this.buffer; - return area == null ? -1 : area.getLong(0); - } - - private void markProducerStalled() { - if (!producerStalled) { - producerStalled = true; - logger.log(Level.WARNING, - () -> "The PowerJoular ring buffer " + path + " has published no new cycle for " - + MAX_WINDOW_NANOS / 1_000_000L + " ms, so monitoring windows are no longer aligned to it and fall back to a fixed " - + DEFAULT_WINDOW_NANOS / 1_000_000L + " ms. Is PowerJoular still running with -r? Results stay on their usual cadence, but each window and the power charged to it may now describe slightly different seconds."); - } - } - - private void markProducerPublishing() { - if (producerStalled) { - producerStalled = false; - logger.log(Level.INFO, - () -> "The PowerJoular ring buffer " + path + " is publishing cycles again. Monitoring windows are realigned to it."); - } - } - - /** - * Reads cpu_power from the newest complete entry. - * - * @return the power in watts, or {@code null} when this cycle could not be read. - */ - private Double readPower() { - ByteBuffer area = this.buffer; - if (area == null) { - area = attach(); - if (area == null) { - return null; - } - } - - // Seqlock read: the producer is a separate process, so the entry the counter points at may still be mid-write. Read the counter, then the entry, then the counter again. - // A counter that moved means the entry may be torn, so the sample is dropped - long head1 = area.getLong(0); - if (head1 <= 0) { - return null; - } - - // The fences pair with the __sync_synchronize() PowerJoular issues before publishing the counter. - // ByteBuffer loads are plain, so without them a processor that reorders its reads, as the ARM ones of Apple Silicon and the Raspberry Pi do, could pair a new counter with a previous entry - VarHandle.acquireFence(); - - int index = (int) ((head1 - 1) % BUFFER_SIZE); - int offset = 8 + index * ENTRY_SIZE; - long timestamp = area.getLong(offset); - double value = area.getDouble(offset + CPU_POWER_OFFSET); - - VarHandle.acquireFence(); - - if (head1 != area.getLong(0)) { - return null; - } - - if (isStale(timestamp)) { - return null; - } - - if (!Double.isFinite(value) || value < 0) { - // PowerJoular writes -1 for a value it could not read at all. - return null; - } - - return value; - } - - /** - * Maps the area, or returns {@code null} when it is not there yet or is too short to hold a complete ring. A short file means PowerJoular is between creating and sizing it - */ - private ByteBuffer attach() { - try (RandomAccessFile file = new RandomAccessFile(path, "r")) { - if (file.length() < FILE_SIZE) { - // Mapping past the end of a file faults on access rather than failing here. - return null; - } - ByteBuffer mapped = file.getChannel().map(FileChannel.MapMode.READ_ONLY, 0, FILE_SIZE); - mapped.order(ByteOrder.nativeOrder()); - this.buffer = mapped; - attachWarningLogged = false; - logger.log(Level.FINE, () -> "Attached to the PowerJoular ring buffer: " + path); - return mapped; - } catch (IOException | RuntimeException e) { - if (!attachWarningLogged) { - logger.log(Level.WARNING, - () -> "Could not open the PowerJoular ring buffer " + path - + ". Ensure PowerJoular is running with -r. Retrying on every cycle."); - attachWarningLogged = true; - } - return null; - } - } - - private static boolean isStale(long timestamp) { - // A timestamp ahead of us is left alone: the clock may have stepped, and that is not staleness - return System.currentTimeMillis() / 1000L - timestamp > MAX_ENTRY_AGE_SECONDS; - } - - @Override - public void close() { - // The mapping is released by the garbage collector, so dropping the reference is all we can do - buffer = null; - } -} diff --git a/src/main/java/org/noureddine/joular/joularcodejava/agent/source/PowerSource.java b/src/main/java/org/noureddine/joular/joularcodejava/agent/source/PowerSource.java deleted file mode 100644 index 356d8a7..0000000 --- a/src/main/java/org/noureddine/joular/joularcodejava/agent/source/PowerSource.java +++ /dev/null @@ -1,90 +0,0 @@ -/* - * Copyright (c) 2026, Adel Noureddine. - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the - * GNU Lesser General Public License v3.0 (LGPL-3.0-only) - * which accompanies this distribution, and is available at - * https://www.gnu.org/licenses/lgpl-3.0.en.html - * - * Author : Adel Noureddine - */ - -package org.noureddine.joular.joularcodejava.agent.source; - -/** - * Interface for different power data sources. - * - *

All implementations must follow the same failure semantics: - *

    - *
  • Before any successful read, {@link #getCurrentPower()} returns {@code 0.0}.
  • - *
  • On a successful read, the returned value is cached as the "last known power".
  • - *
  • On transient errors (mid-write torn reads, parse failures, missing row, temporarily - * unreadable device data), implementations return the last known power so the agent smooths - * over momentary producer unavailability.
  • - *
  • That cover is bounded. After a handful of consecutive unreadable cycles, implementations - * backed by an external producer report {@code 0.0} rather than going on replaying a value - * that is no longer true, and log a warning once. Zero means no energy is attributed for the - * cycle, which is preferable to attributing energy that was never measured.
  • - *
  • Implementations must reject {@code NaN}, infinite, and negative values returned by - * the underlying source — these are treated as transient errors.
  • - *
  • Returned values are CPU power in Watts and must be {@code >= 0} and finite.
  • - *
- */ -public interface PowerSource { - - /** - * How many consecutive unreadable cycles an external source (csv, ring buffer) covers with the last known value before reporting {@code 0.0}. - */ - int MAX_STALE_CYCLES = 5; - - /** - * Initialize the power source connection. - * - * @throws Exception if the source cannot be opened. The agent will log and abort startup. - */ - void initialize() throws Exception; - - /** - * Get the current power consumption in Watts. - * - * @return power in Watts; always finite and {@code >= 0}. - */ - double getCurrentPower(); - - /** How long an attribution window runs for a source that has no cadence of its own to follow - */ - long DEFAULT_WINDOW_NANOS = 1_000_000_000L; - - /** - * Called by the agent as it opens an attribution window, before the first stack sample. - * - *

A source that closes its windows on its producer's cadence latches here whatever it needs to recognise the next measurement. - * The default does nothing, which is right for a source whose windows are simply a fixed length. - */ - default void beginWindow() { - // Nothing to latch: the default policy is a fixed-length window - } - - /** - * Whether the attribution window opened by the last {@link #beginWindow()}, and running for {@code elapsedNs} so far, should be closed now. - * - *

This is where a source decides how its own cadence maps onto the agent's windows, because it is the only party that knows that cadence. - * A source fed by an external producer that publishes an average over the second ending at T closes the window on the edge where that value lands, so the stack samples in the window describe the same second as the power they are charged with. - * It also owns any bound on that wait, since the sensible bound follows from the cadence. - * The default is the fixed {@link #DEFAULT_WINDOW_NANOS} window, which is right for a source read on demand rather than published to. - * - *

Called on every sample tick, so it must be cheap and must not block. - * - * @param elapsedNs nanoseconds since {@link #beginWindow()} was called - * @return {@code true} to close the window now - */ - default boolean isWindowComplete(long elapsedNs) { - return elapsedNs >= DEFAULT_WINDOW_NANOS; - } - - /** - * Close the power source connection. Must be safe to call even if {@link #initialize()} - * was never called or threw. - */ - void close(); -} diff --git a/src/main/java/org/noureddine/joular/joularcodejava/agent/source/PowerSourceFactory.java b/src/main/java/org/noureddine/joular/joularcodejava/agent/source/PowerSourceFactory.java deleted file mode 100644 index ad16913..0000000 --- a/src/main/java/org/noureddine/joular/joularcodejava/agent/source/PowerSourceFactory.java +++ /dev/null @@ -1,42 +0,0 @@ -/* - * Copyright (c) 2026, Adel Noureddine. - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the - * GNU Lesser General Public License v3.0 (LGPL-3.0-only) - * which accompanies this distribution, and is available at - * https://www.gnu.org/licenses/lgpl-3.0.en.html - * - * Author : Adel Noureddine - */ - -package org.noureddine.joular.joularcodejava.agent.source; - -import java.util.Locale; -import java.util.logging.Level; -import java.util.logging.Logger; -import org.noureddine.joular.joularcodejava.agent.utils.AgentProperties; - -public class PowerSourceFactory { - - private static final Logger logger = Logger.getLogger(PowerSourceFactory.class.getName()); - - public static PowerSource getPowerSource(AgentProperties properties) { - String type = properties.getPowerSourceType(); - String normalizedType = type.trim().toLowerCase(Locale.ROOT); - logger.log(Level.INFO, () -> "Selecting power source type: " + type); - try { - return switch (normalizedType) { - case "csv" -> new PowerJoularCsvSource(properties.getPowerJoularCsvPath()); - case "ringbuffer" -> new PowerJoularRingBufferSource(properties.getPowerJoularRingBufferPath()); - case "rapl" -> new LinuxRaplPowerSource(); - default -> { - logger.log(Level.SEVERE, () -> "Unknown power source type: " + type); - yield null; - } - }; - } catch (IllegalArgumentException e) { - logger.log(Level.SEVERE, "Invalid configuration for power source '" + type + "': " + e.getMessage()); - return null; - } - } -} diff --git a/src/main/java/org/noureddine/joular/joularcodejava/agent/utils/AgentProperties.java b/src/main/java/org/noureddine/joular/joularcodejava/agent/utils/AgentProperties.java deleted file mode 100644 index 2d0faf1..0000000 --- a/src/main/java/org/noureddine/joular/joularcodejava/agent/utils/AgentProperties.java +++ /dev/null @@ -1,149 +0,0 @@ -/* - * Copyright (c) 2026, Adel Noureddine. - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the - * GNU Lesser General Public License v3.0 (LGPL-3.0-only) - * which accompanies this distribution, and is available at - * https://www.gnu.org/licenses/lgpl-3.0.en.html - * - * Author : Adel Noureddine - */ - -package org.noureddine.joular.joularcodejava.agent.utils; - -import java.io.IOException; -import java.io.InputStream; -import java.io.InputStreamReader; -import java.io.Reader; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; -import java.util.Locale; -import java.util.Properties; -import java.util.logging.Level; -import java.util.logging.Logger; - -public class AgentProperties { - - private static final Logger logger = Logger.getLogger(AgentProperties.class.getName()); - private final Properties properties = new Properties(); - - public AgentProperties() { - loadClasspathDefaults(); - - // Check system properties; fall back to joularcodejava.properties in the working directory - String rawPath = System.getProperty("joularcodejava.properties"); - Path configFilePath = (rawPath == null || rawPath.isEmpty()) - ? Path.of(System.getProperty("user.dir")).resolve("joularcodejava.properties") - : Path.of(rawPath); - - // If file does not exist, load defaults - if (!Files.exists(configFilePath) || !Files.isRegularFile(configFilePath)) { - logger.log(Level.INFO, "Could not load properties from " + configFilePath + ", using defaults."); - return; - } - - // Load properties from file (UTF-8 so users can write non-ASCII paths/values) - try (Reader in = Files.newBufferedReader(configFilePath, StandardCharsets.UTF_8)) { - properties.load(in); - } catch (IOException e) { - logger.log(Level.WARNING, "Could not load properties from " + configFilePath + ", using defaults."); - } - } - - public String getPowerSourceType() { - return getStringProperty("power-source-type", "ringbuffer"); - } - - public String getPowerJoularCsvPath() { - return getStringProperty("powerjoular-csv-path", "powerjoular-data.csv"); - } - - public String getPowerJoularRingBufferPath() { - String defaultPath; - String os = System.getProperty("os.name").toLowerCase(Locale.ROOT); - if (os.contains("win")) { - // PowerJoular puts the ring buffer under ProgramData so that any user can read it. - String programData = System.getenv("PROGRAMDATA"); - if (programData == null || programData.trim().isEmpty()) { - programData = "C:\\ProgramData"; - } - defaultPath = programData + "\\joularcorering"; - } else if (os.contains("mac")) { - defaultPath = "/tmp/joularcorering"; - } else { - defaultPath = "/dev/shm/joularcorering"; - } - return getStringProperty("powerjoular-ringbuffer-path", defaultPath); - } - - public long getSampleRateMs() { - return getPositiveLongProperty("stack-monitoring-sample-rate", 10L); - } - - public String getResultsPath() { - return getStringProperty("results-path", "joular-code-java-results"); - } - - public List getMethodsFilteringPrefixes() { - String value = getStringProperty("methods-filtering-prefix", ""); - if (value.isEmpty()) { - return Collections.emptyList(); - } - - String[] rawPrefixes = value.split(","); - List prefixes = new ArrayList<>(rawPrefixes.length); - for (String prefix : rawPrefixes) { - if (prefix != null) { - String cleaned = prefix.trim(); - if (!cleaned.isEmpty()) { - prefixes.add(cleaned); - } - } - } - - return prefixes; - } - - private void loadClasspathDefaults() { - try (InputStream in = AgentProperties.class.getClassLoader().getResourceAsStream("joularcodejava.properties")) { - if (in == null) { - return; - } - try (Reader reader = new InputStreamReader(in, StandardCharsets.UTF_8)) { - properties.load(reader); - } - } catch (IOException e) { - logger.log(Level.FINE, "Could not load classpath default properties.", e); - } - } - - private String getStringProperty(String key, String defaultValue) { - String value = properties.getProperty(key); - if (value == null) { - return defaultValue; - } - String trimmed = value.trim(); - return trimmed.isEmpty() ? defaultValue : trimmed; - } - - private long getPositiveLongProperty(String key, long defaultValue) { - String rawValue = properties.getProperty(key); - if (rawValue == null || rawValue.trim().isEmpty()) { - return defaultValue; - } - try { - long value = Long.parseLong(rawValue.trim()); - if (value > 0) { - return value; - } - logger.log(Level.WARNING, () -> "Property " + key + " must be > 0. Using default: " + defaultValue); - } catch (NumberFormatException e) { - logger.log(Level.WARNING, () -> "Property " + key + " has invalid value '" + rawValue + "'. Using default: " + defaultValue); - } - return defaultValue; - } -} diff --git a/src/main/resources/joularcodejava.properties b/src/main/resources/joularcodejava.properties deleted file mode 100644 index 1b87cb3..0000000 --- a/src/main/resources/joularcodejava.properties +++ /dev/null @@ -1,51 +0,0 @@ -# Copyright (c) 2026, Adel Noureddine. -# All rights reserved. This program and the accompanying materials -# are made available under the terms of the -# GNU Lesser General Public License v3.0 (LGPL-3.0-only) -# which accompanies this distribution, and is available at -# https://www.gnu.org/licenses/lgpl-3.0.en.html -# -# Author : Adel Noureddine - -# Joular Code - Java: Configuration -# -# Power source type -# Options: -# ringbuffer (read from the shared memory ring buffer PowerJoular writes with -r) -# csv (read from the CSV file PowerJoular writes with -f or -o) -# rapl (read from RAPL on Linux directly, without PowerJoular) -# Default if left empty: ringbuffer -power-source-type= - -# Path to the PowerJoular CSV file (if power-source-type is csv) -# This must be the file holding the power of the whole system, not the one PowerJoular writes for a monitored process -# Default if left empty: powerjoular-data.csv (relative to working directory) -powerjoular-csv-path= - -# Path to the ring buffer (if power-source-type is ringbuffer) -# Default if left empty (OS-dependent): -# Linux: /dev/shm/joularcorering -# macOS: /tmp/joularcorering -# Windows: %PROGRAMDATA%\\joularcorering -powerjoular-ringbuffer-path= - -# The sample rate (milliseconds) for the agent to monitor methods usage -# by calling the JVM stack -# Higher count means less accurate energy data -# Lower count means higher overhead of the agent -# Default if left empty: 10 (good balance between accuracy and efficiency) -stack-monitoring-sample-rate= - -# Output folder for results -# Default if left empty: joular-code-java-results -results-path= - -# Prefix to filter monitoring of methods -# If set, only methods where their full canonical name (package.class.method) starting with this prefix will be monitored -# If not set, all observed branches appear in both output files. -# Example: methods-filtering-prefix=com.example to filter energy for all methods whose full name start with com.example -# Note that Joular Code Java will always generate energy for all non-filtered methods in a separate file -# You can add multiple identifiers names separated by a comma, ordered by priority -# Example: methods-filtering-prefix=com.example,org.myapp -# Default if left empty: no filter (all branches go to both output files) -methods-filtering-prefix= diff --git a/src/test/java/org/noureddine/joular/joularcodejava/agent/AgentEntryPointTest.java b/src/test/java/org/noureddine/joular/joularcodejava/agent/AgentEntryPointTest.java index 3fb6b8b..ba877b3 100644 --- a/src/test/java/org/noureddine/joular/joularcodejava/agent/AgentEntryPointTest.java +++ b/src/test/java/org/noureddine/joular/joularcodejava/agent/AgentEntryPointTest.java @@ -17,40 +17,26 @@ import java.lang.reflect.Method; import java.lang.reflect.Modifier; -import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; -/** - * Guards the two entry points the JVM looks up by name. - * - *

The manifest names this class for both {@code Premain-Class} and {@code Agent-Class}, and the - * JVM resolves the matching method reflectively at load time. Nothing in the Java compiler ties the - * two together, so the manifest once advertised {@code Agent-Class} while no {@code agentmain} - * existed: every attach to a running JVM failed, and no test noticed. These assertions are the - * missing link, and they fail at build time rather than at someone else's attach. - * - *

They deliberately only inspect the signatures. Calling either method starts a monitoring - * thread and writes result files, which belongs in an integration run rather than a unit test. - */ +/** The manifest names Agent for Premain-Class and Agent-Class, and the JVM looks these two methods up by signature. */ class AgentEntryPointTest { /** Loaded by the JVM when the agent is given on the command line with {@code -javaagent:}. */ @Test - void premain_hasTheSignatureTheJvmLooksUp() { + void premain_hasTheSignatureTheJvmLooksUp() throws Exception { assertIsAgentEntryPoint("premain"); } /** Loaded by the JVM when the agent is attached to an already running process. */ @Test - void agentmain_hasTheSignatureTheAttachApiLooksUp() { + void agentmain_hasTheSignatureTheAttachApiLooksUp() throws Exception { assertIsAgentEntryPoint("agentmain"); } - private static void assertIsAgentEntryPoint(String name) { - Method method = assertDoesNotThrow( - () -> Agent.class.getMethod(name, String.class, Instrumentation.class), - name + "(String, Instrumentation) must exist for the JVM to find it"); + private static void assertIsAgentEntryPoint(String name) throws Exception { + Method method = Agent.class.getMethod(name, String.class, Instrumentation.class); int modifiers = method.getModifiers(); assertTrue(Modifier.isPublic(modifiers), name + " must be public"); diff --git a/src/test/java/org/noureddine/joular/joularcodejava/agent/BranchTest.java b/src/test/java/org/noureddine/joular/joularcodejava/agent/BranchTest.java new file mode 100644 index 0000000..5ff58ee --- /dev/null +++ b/src/test/java/org/noureddine/joular/joularcodejava/agent/BranchTest.java @@ -0,0 +1,125 @@ +/* + * Copyright (c) 2026, Adel Noureddine. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the + * GNU Lesser General Public License v3.0 (LGPL-3.0-only) + * which accompanies this distribution, and is available at + * https://www.gnu.org/licenses/lgpl-3.0.en.html + * + * Author : Adel Noureddine + */ + +package org.noureddine.joular.joularcodejava.agent; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import org.junit.jupiter.api.Test; + +/** Branch identity, which decides what adds up, and its two names, which feed the two result files. */ +class BranchTest { + + /** + * A branch the way the JVM hands a stack over: newest frame at index 0. + * + * @param methods {@code package.Class.method} names, oldest first, which is the order they come back out in + */ + static Branch branch(String... methods) { + StackTraceElement[] frames = new StackTraceElement[methods.length]; + for (int i = 0; i < methods.length; i++) { + String qualified = methods[methods.length - 1 - i]; + int lastDot = qualified.lastIndexOf('.'); + frames[i] = new StackTraceElement(qualified.substring(0, lastDot), qualified.substring(lastDot + 1), null, i); + } + return Branch.of(frames); + } + + @Test + void name_walksTheStackOldestFrameFirst() { + assertEquals("com.example.Main.main;com.example.Worker.compute", + branch("com.example.Main.main", "com.example.Worker.compute").name()); + } + + @Test + void name_singleFrame_hasNoSeparator() { + assertEquals("com.example.Main.main", branch("com.example.Main.main").name()); + } + + /** Samples on different lines of the same methods are one branch, so their counts add up. */ + @Test + void equals_ignoresLineNumbers() { + StackTraceElement[] onLine10 = {new StackTraceElement("a.Worker", "compute", "Worker.java", 10)}; + StackTraceElement[] onLine20 = {new StackTraceElement("a.Worker", "compute", "Worker.java", 20)}; + + assertEquals(Branch.of(onLine10), Branch.of(onLine20)); + assertEquals(Branch.of(onLine10).hashCode(), Branch.of(onLine20).hashCode()); + } + + @Test + void equals_differentMethodsOrDepth_areDifferentBranches() { + assertNotEquals(branch("a.Main.main", "a.Worker.compute"), branch("a.Main.main", "a.Worker.save")); + assertNotEquals(branch("a.Main.main", "a.Worker.compute"), branch("a.Main.main")); + assertNotEquals(branch("a.Main.main", "a.Worker.compute"), branch("a.Worker.compute", "a.Main.main")); + } + + @Test + void appName_keepsOnlyMatchingFrames() { + Branch branch = branch("java.lang.Thread.run", "com.example.Worker.compute", "java.util.HashMap.put"); + + assertEquals("java.lang.Thread.run;com.example.Worker.compute;java.util.HashMap.put", branch.name()); + assertEquals("com.example.Worker.compute", branch.appName(List.of("com.example"))); + } + + @Test + void appName_nothingMatches_isEmpty() { + assertEquals("", branch("java.lang.Thread.run", "java.util.HashMap.put").appName(List.of("com.example")), + "an unmatched stack contributes nothing to the app file"); + } + + /** Unmatched frames called by a matched method collapse onto it, so stacks that differ only below it add up. */ + @Test + void appName_unmatchedFramesBelowAMatch_collapseOntoTheMatchedMethod() { + List prefixes = List.of("com.example"); + + assertEquals("com.example.Service.save", + branch("com.example.Service.save", "java.util.HashMap.put").appName(prefixes)); + assertEquals("com.example.Service.save", + branch("com.example.Service.save", "java.io.FileOutputStream.write").appName(prefixes)); + } + + @Test + void appName_multiplePrefixes_eachOneMatches() { + assertEquals("org.myapp.Boot.start;com.example.Worker.compute", + branch("org.myapp.Boot.start", "java.util.HashMap.put", "com.example.Worker.compute") + .appName(List.of("com.example", "org.myapp"))); + } + + /** The prefix is plain text, not a package boundary, as documented for the property. */ + @Test + void appName_prefixMatchesTextNotPackageBoundaries() { + assertEquals("com.examples.Other.run;com.example.Worker.compute", + branch("com.examples.Other.run", "com.example.Worker.compute").appName(List.of("com.example"))); + } + + @Test + void startsWith_prefixReachingIntoTheMethodName() { + assertTrue(Branch.startsWith("com.example.Worker", "compute", "com.example.Worker.com")); + assertTrue(Branch.startsWith("com.example.Worker", "compute", "com.example.Worker.compute")); + assertTrue(Branch.startsWith("com.example.Worker", "compute", "com.example.Worker.")); + + assertFalse(Branch.startsWith("com.example.Worker", "compute", "com.example.Worker.x")); + assertFalse(Branch.startsWith("com.example.Worker", "compute", "com.example.Worker.computed"), + "a prefix longer than the whole name cannot match"); + assertFalse(Branch.startsWith("com.example.Worker", "compute", "com.example.Workers")); + } + + @Test + void startsWith_prefixInsideTheClassName() { + assertTrue(Branch.startsWith("com.example.Worker", "compute", "com.example")); + assertTrue(Branch.startsWith("com.example.Worker", "compute", "com.example.Worker")); + assertFalse(Branch.startsWith("com.example.Worker", "compute", "org.other")); + } +} diff --git a/src/test/java/org/noureddine/joular/joularcodejava/agent/ConfigTest.java b/src/test/java/org/noureddine/joular/joularcodejava/agent/ConfigTest.java new file mode 100644 index 0000000..3e75f75 --- /dev/null +++ b/src/test/java/org/noureddine/joular/joularcodejava/agent/ConfigTest.java @@ -0,0 +1,126 @@ +/* + * Copyright (c) 2026, Adel Noureddine. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the + * GNU Lesser General Public License v3.0 (LGPL-3.0-only) + * which accompanies this distribution, and is available at + * https://www.gnu.org/licenses/lgpl-3.0.en.html + * + * Author : Adel Noureddine + */ + +package org.noureddine.joular.joularcodejava.agent; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.io.StringReader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Locale; +import java.util.Properties; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class ConfigTest { + + @TempDir + Path tempDir; + + @AfterEach + void clearSystemProperty() { + System.clearProperty("joularcodejava.properties"); + } + + private static Config config(String content) throws IOException { + Properties properties = new Properties(); + properties.load(new StringReader(content)); + return Config.from(properties); + } + + @Test + void defaults() throws IOException { + Config config = config(""); + + assertEquals("auto", config.powerSourceType()); + assertNull(config.vmPowerFile()); + assertEquals("powerjoular", config.vmPowerFormat()); + assertEquals(10L, config.sampleRateMs()); + assertEquals(Path.of("joular-code-java-results"), config.resultsDir()); + assertTrue(config.appPrefixes().isEmpty()); + } + + /** A value of blanks only is the same as no value. */ + @Test + void blankValue_usesTheDefault() throws IOException { + assertEquals("auto", config("power-source-type= \n").powerSourceType()); + } + + @Test + void values_areReadAndTrimmed() throws IOException { + Config config = config(""" + power-source-type = RingBuffer + powerjoular-ringbuffer-path = /custom/ring + vm-power-file = /mnt/host/vm-power.csv + vm-power-format = Watts + stack-monitoring-sample-rate = 50 + results-path = out + """); + + assertEquals("ringbuffer", config.powerSourceType(), "the type is not case sensitive"); + assertEquals(Path.of("/custom/ring"), config.ringBufferPath()); + assertEquals(Path.of("/mnt/host/vm-power.csv"), config.vmPowerFile()); + assertEquals("watts", config.vmPowerFormat()); + assertEquals(50L, config.sampleRateMs()); + assertEquals(Path.of("out"), config.resultsDir()); + } + + @ParameterizedTest + @ValueSource(strings = {"0", "-5", "fast", "1001", "99999999999999999999"}) + void invalidSampleRate_usesTheDefault(String value) throws IOException { + assertEquals(Config.DEFAULT_SAMPLE_RATE_MS, config("stack-monitoring-sample-rate=" + value).sampleRateMs()); + } + + @Test + void prefixes_areSplitTrimmedAndEmptyOnesDropped() throws IOException { + assertEquals(List.of("com.example", "org.myapp", "io.foo"), + config("methods-filtering-prefix=com.example, org.myapp ,, io.foo").appPrefixes()); + } + + @Test + void ringBufferPath_defaultDependsOnTheOs() throws IOException { + String os = System.getProperty("os.name").toLowerCase(Locale.ROOT); + String path = config("").ringBufferPath().toString(); + + if (os.contains("win")) { + assertTrue(path.endsWith("\\powerjoular"), path); + } else if (os.contains("mac")) { + assertEquals("/tmp/powerjoular", path); + } else { + assertEquals("/dev/shm/powerjoular", path); + } + } + + @Test + void load_readsTheFileNamedBySystemProperty() throws IOException { + Path file = tempDir.resolve("custom.properties"); + Files.writeString(file, "stack-monitoring-sample-rate=25\n", StandardCharsets.UTF_8); + System.setProperty("joularcodejava.properties", file.toString()); + + assertEquals(25L, Config.load().sampleRateMs()); + } + + @Test + void load_missingFile_usesDefaults() { + System.setProperty("joularcodejava.properties", tempDir.resolve("missing.properties").toString()); + + assertEquals(Config.DEFAULT_SAMPLE_RATE_MS, Config.load().sampleRateMs()); + } +} diff --git a/src/test/java/org/noureddine/joular/joularcodejava/agent/LinuxRaplTest.java b/src/test/java/org/noureddine/joular/joularcodejava/agent/LinuxRaplTest.java new file mode 100644 index 0000000..08c6374 --- /dev/null +++ b/src/test/java/org/noureddine/joular/joularcodejava/agent/LinuxRaplTest.java @@ -0,0 +1,155 @@ +/* + * Copyright (c) 2026, Adel Noureddine. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the + * GNU Lesser General Public License v3.0 (LGPL-3.0-only) + * which accompanies this distribution, and is available at + * https://www.gnu.org/licenses/lgpl-3.0.en.html + * + * Author : Adel Noureddine + */ + +package org.noureddine.joular.joularcodejava.agent; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.DisabledOnOs; +import org.junit.jupiter.api.condition.OS; +import org.junit.jupiter.api.io.TempDir; + +/** + * RAPL read from a fake powercap tree. Its zone names hold a ':', which Windows does not allow in a file name, so + * those tests only run elsewhere. + */ +class LinuxRaplTest { + + private static final long SECOND = 1_000_000_000L; + private static final long MAX_UJ = 10_000_000; + + @TempDir + Path powercap; + + /** The time the source reads, moved by the tests. */ + private long now = 0; + + private LinuxRapl open() throws IOException { + return LinuxRapl.open(powercap, () -> now); + } + + private void zone(String dirName, String name, long energyUj) throws IOException { + Path zone = Files.createDirectories(powercap.resolve(dirName)); + Files.writeString(zone.resolve("name"), name + "\n"); + Files.writeString(zone.resolve("max_energy_range_uj"), MAX_UJ + "\n"); + energy(dirName, String.valueOf(energyUj)); + } + + private void energy(String dirName, String energyUj) throws IOException { + Files.writeString(powercap.resolve(dirName).resolve("energy_uj"), energyUj + "\n"); + } + + @Test + @DisabledOnOs(OS.WINDOWS) + void watts_isTheEnergyUsedOverTheTimeElapsed() throws IOException { + zone("intel-rapl:0", "package-0", 1_000_000); + LinuxRapl rapl = open(); + + energy("intel-rapl:0", "5000000"); + now += 2 * SECOND; + + assertEquals(2.0, rapl.watts(), 1e-9); + } + + /** Sub-zones are part of their package, and psys covers more than the CPU. */ + @Test + @DisabledOnOs(OS.WINDOWS) + void watts_sumsThePackagesOnly() throws IOException { + zone("intel-rapl:0", "package-0", 0); + zone("intel-rapl:1", "package-1", 0); + zone("intel-rapl:0:0", "core", 0); + zone("intel-rapl:2", "psys", 0); + LinuxRapl rapl = open(); + + for (String dir : new String[] {"intel-rapl:0", "intel-rapl:1", "intel-rapl:0:0", "intel-rapl:2"}) { + energy(dir, "1000000"); + } + now += SECOND; + + assertEquals(2.0, rapl.watts(), 1e-9, "one joule from each package, nothing from the other zones"); + } + + @Test + @DisabledOnOs(OS.WINDOWS) + void watts_counterWrapped_countsTheEnergyAcrossTheWrap() throws IOException { + zone("intel-rapl:0", "package-0", MAX_UJ - 1_000_000); + LinuxRapl rapl = open(); + + energy("intel-rapl:0", "1000000"); + now += SECOND; + + assertEquals(2.0, rapl.watts(), 1e-9); + } + + /** A failed reading leaves the counters alone, so the next good one covers the whole time since the last. */ + @Test + @DisabledOnOs(OS.WINDOWS) + void watts_afterAFailedReading_averagesOverTheLongerSpan() throws IOException { + zone("intel-rapl:0", "package-0", 0); + LinuxRapl rapl = open(); + + for (String garbage : new String[] {"not-a-number", "-1", String.valueOf(MAX_UJ + 1)}) { + energy("intel-rapl:0", garbage); + now += SECOND; + assertThrows(IOException.class, rapl::watts, garbage); + } + + energy("intel-rapl:0", "8000000"); + now += SECOND; + assertEquals(2.0, rapl.watts(), 1e-9, "8 J over 4 s"); + } + + /** After a suspend the counter restarts near 0, which is not a wrap: that window gets no energy. */ + @Test + @DisabledOnOs(OS.WINDOWS) + void watts_counterReset_countsNothingForThatWindow() throws IOException { + zone("intel-rapl:0", "package-0", 2_000_000); + LinuxRapl rapl = open(); + + energy("intel-rapl:0", "1000000"); + now += SECOND; + assertEquals(0.0, rapl.watts(), 1e-9); + + energy("intel-rapl:0", "3000000"); + now += SECOND; + assertEquals(2.0, rapl.watts(), 1e-9, "counting on from the reset value"); + } + + @Test + @DisabledOnOs(OS.WINDOWS) + void open_noPackageZone_throws() throws IOException { + zone("intel-rapl:0", "psys", 0); + + assertThrows(IOException.class, this::open); + } + + @Test + @DisabledOnOs(OS.WINDOWS) + void open_malformedRange_throws() throws IOException { + zone("intel-rapl:0", "package-0", 0); + Files.writeString(powercap.resolve("intel-rapl:0/max_energy_range_uj"), "lots\n"); + + assertThrows(IOException.class, this::open); + } + + @Test + void energyBetween() { + assertEquals(300, LinuxRapl.energyBetween(100, 400, 1000)); + assertEquals(0, LinuxRapl.energyBetween(400, 400, 1000)); + assertEquals(150, LinuxRapl.energyBetween(900, 50, 1000), "wrapped past 1000"); + assertEquals(0, LinuxRapl.energyBetween(100, 50, 1000), "a jump back over half the range is a reset"); + } +} diff --git a/src/test/java/org/noureddine/joular/joularcodejava/agent/MonitorTest.java b/src/test/java/org/noureddine/joular/joularcodejava/agent/MonitorTest.java new file mode 100644 index 0000000..a56579c --- /dev/null +++ b/src/test/java/org/noureddine/joular/joularcodejava/agent/MonitorTest.java @@ -0,0 +1,192 @@ +/* + * Copyright (c) 2026, Adel Noureddine. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the + * GNU Lesser General Public License v3.0 (LGPL-3.0-only) + * which accompanies this distribution, and is available at + * https://www.gnu.org/licenses/lgpl-3.0.en.html + * + * Author : Adel Noureddine + */ + +package org.noureddine.joular.joularcodejava.agent; + +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 java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.function.BooleanSupplier; +import java.util.logging.Handler; +import java.util.logging.Level; +import java.util.logging.LogRecord; +import java.util.logging.Logger; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * The monitor thread against a fake power source, with a busy thread to measure. The attribution itself is covered + * by {@link PowerModelTest}, since live stacks and CPU times cannot be made deterministic. + */ +class MonitorTest { + + @TempDir + Path tempDir; + + private final List logs = new CopyOnWriteArrayList<>(); + private final Handler logCapture = new Handler() { + @Override + public void publish(LogRecord record) { + logs.add(record); + } + + @Override + public void flush() { + } + + @Override + public void close() { + } + }; + + private final FakeSource source = new FakeSource(); + private Monitor monitor; + private volatile boolean busy = true; + + /** A source with 100 ms windows, so tests do not wait a whole second each. */ + private static final class FakeSource implements PowerSource { + volatile double watts = 50.0; + volatile boolean failing = false; + volatile int reads = 0; + volatile String openedOn; + volatile String closedOn; + + @Override + public double watts() throws IOException { + reads++; + if (failing) { + throw new IOException("the fake source is failing."); + } + return watts; + } + + @Override + public boolean windowOver(long elapsedNs) { + return elapsedNs >= 100_000_000L; + } + + @Override + public void close() { + closedOn = Thread.currentThread().getName(); + } + } + + @BeforeEach + void captureLogs() { + Logger.getLogger(Monitor.class.getName()).addHandler(logCapture); + } + + @AfterEach + void tearDown() { + busy = false; + if (monitor != null) { + monitor.stop(); + } + Logger.getLogger(Monitor.class.getName()).removeHandler(logCapture); + } + + private Monitor monitor(List appPrefixes) throws IOException { + Config config = new Config("auto", tempDir.resolve("ring"), null, "powerjoular", 10, tempDir, appPrefixes); + monitor = new Monitor(config, () -> { + source.openedOn = Thread.currentThread().getName(); + return source; + }); + return monitor; + } + + private void startBusyThread() { + Thread.ofPlatform().daemon().start(() -> { + double x = 0; + while (busy) { + x += Math.sqrt(x + 1); + } + }); + } + + private List rows(String file) { + try { + return Files.readAllLines(tempDir.resolve(file)).stream().skip(1).toList(); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + private static void await(BooleanSupplier condition) throws InterruptedException { + long deadline = System.nanoTime() + 10_000_000_000L; + while (!condition.getAsBoolean()) { + assertTrue(System.nanoTime() < deadline, "timed out"); + Thread.sleep(20); + } + } + + /** The source is opened and closed by the thread that reads it, so a source never has to be thread safe. */ + @Test + void source_isOpenedAndClosedOnTheMonitorThread() throws Exception { + monitor(List.of()).start(); + await(() -> source.reads > 0); + + monitor.stop(); + + assertFalse(monitor.isAlive()); + assertEquals(Monitor.THREAD_NAME, source.openedOn); + assertEquals(Monitor.THREAD_NAME, source.closedOn); + } + + @Test + void busyThread_getsRowsInBothFiles() throws Exception { + startBusyThread(); + monitor(List.of(MonitorTest.class.getName())).start(); + + await(() -> !rows(ResultWriter.APP_METHODS_FILE).isEmpty()); + + assertTrue(rows(ResultWriter.APP_METHODS_FILE).stream().anyMatch(row -> row.contains("MonitorTest.lambda$"))); + assertFalse(rows(ResultWriter.ALL_METHODS_FILE).isEmpty()); + } + + /** Without a reading there is no energy to share: nothing is written, and the loss is logged once. */ + @Test + void failingSource_writesNothingAndWarnsOnce() throws Exception { + source.failing = true; + startBusyThread(); + monitor(List.of()).start(); + + await(() -> source.reads >= 3); + monitor.stop(); + + assertTrue(rows(ResultWriter.ALL_METHODS_FILE).isEmpty()); + assertEquals(1, logs.stream().filter(record -> record.getLevel() == Level.WARNING + && record.getMessage().contains("the fake source is failing")).count()); + } + + /** A source that cannot be opened ends monitoring with its reason, and nothing else. */ + @Test + void openFailure_isLoggedAndMonitoringEnds() throws Exception { + Config config = new Config("auto", tempDir.resolve("ring"), null, "powerjoular", 10, tempDir, List.of()); + monitor = new Monitor(config, () -> { + throw new IOException("No power here."); + }); + monitor.start(); + + await(() -> !monitor.isAlive()); + + assertTrue(logs.stream().anyMatch(record -> record.getLevel() == Level.SEVERE + && record.getMessage().contains("No power here."))); + } +} diff --git a/src/test/java/org/noureddine/joular/joularcodejava/agent/MonitoringHandlerTest.java b/src/test/java/org/noureddine/joular/joularcodejava/agent/MonitoringHandlerTest.java deleted file mode 100644 index edfe6cc..0000000 --- a/src/test/java/org/noureddine/joular/joularcodejava/agent/MonitoringHandlerTest.java +++ /dev/null @@ -1,445 +0,0 @@ -/* - * Copyright (c) 2026, Adel Noureddine. - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the - * GNU Lesser General Public License v3.0 (LGPL-3.0-only) - * which accompanies this distribution, and is available at - * https://www.gnu.org/licenses/lgpl-3.0.en.html - * - * Author : Adel Noureddine - */ - -package org.noureddine.joular.joularcodejava.agent; - -import com.sun.management.OperatingSystemMXBean; -import com.sun.management.ThreadMXBean; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.junit.jupiter.api.io.TempDir; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.noureddine.joular.joularcodejava.agent.source.PowerSource; -import org.noureddine.joular.joularcodejava.agent.utils.AgentProperties; - -import java.lang.management.ManagementFactory; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.Properties; -import java.util.logging.Handler; -import java.util.logging.Level; -import java.util.logging.LogRecord; -import java.util.logging.Logger; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; - -/** - * Unit tests for {@link MonitoringHandler}. - * - *

These tests focus on the startup lifecycle and thread-management API rather - * than the energy-attribution algorithm, which depends on live stack traces and - * CPU time measurements that cannot be made deterministic in a unit-test context. - * - *

What is tested

- *
    - *
  • Startup success path: {@code awaitStartup} returns {@code true} and - * {@code isStartupSuccessful} returns {@code true}.
  • - *
  • Startup failure paths: a throwing {@link PowerSource#initialize()} and an - * unwritable results directory both release the startup latch with a failure - * flag set.
  • - *
  • Thread lifecycle: {@code stop()} + {@code joinWithTimeout()} reliably - * terminates the monitoring thread.
  • - *
  • Pre-start guards: {@code stop()}, {@code joinWithTimeout()}, - * {@code isStartupSuccessful()}, and {@code getStartupFailure()} all behave - * correctly when called before the thread has been started.
  • - *
- * - *

JDK MX bean strategy

- *

The {@link ThreadMXBean} and {@link OperatingSystemMXBean} interfaces belong - * to closed JDK modules. Byte Buddy (used by Mockito) cannot instrument them - * without special JVM flags on recent JDK versions. To avoid this limitation, the - * tests obtain real singleton beans from {@link ManagementFactory}. Only - * {@link PowerSource} — a project-defined interface — is mocked. - * - *

Test isolation

- *

{@code @AfterEach} stops and joins any running handler thread and clears the - * {@code joularcodejava.properties} system property so tests cannot interfere - * with each other. - */ -@ExtendWith(MockitoExtension.class) -class MonitoringHandlerTest { - - /** Mocked power source — controls initialize() and getCurrentPower() behaviour. */ - @Mock - PowerSource mockPowerSource; - - /** - * Real JDK thread bean (com.sun.management flavour required by MonitoringHandler). - * Cannot be mocked without Byte Buddy experimental mode due to module - * encapsulation in JDK 17+. - */ - private final ThreadMXBean threadBean = - (ThreadMXBean) ManagementFactory.getThreadMXBean(); - - /** - * Real JDK OS bean (com.sun.management flavour required by MonitoringHandler). - * Same module-encapsulation constraint as ThreadMXBean. - */ - private final OperatingSystemMXBean osBean = - ManagementFactory.getPlatformMXBean(OperatingSystemMXBean.class); - - @TempDir - Path tempDir; - - private MonitoringHandler handler; - private Thread handlerThread; - - @AfterEach - void tearDown() { - System.clearProperty("joularcodejava.properties"); - if (handler != null) { - handler.stop(); - handler.joinWithTimeout(2000); - } - } - - /** - * Writes a minimal properties file to the temp directory and constructs a - * {@link MonitoringHandler} using the provided results path. - * A 100 ms sample rate is used so the monitoring loop cycles quickly and - * tests remain fast. - * - *

{@link Properties#store} is used instead of plain string concatenation so - * that Windows paths with backslashes are properly double-escaped (e.g., - * {@code C:\tmp} → {@code C:\\tmp}). Without this, {@link Properties#load} - * would silently consume the backslashes as escape-sequence prefixes and - * resolve an entirely different path, causing the unwritable-path test to - * pass startup checks on Windows when it should fail. - */ - private MonitoringHandler makeHandler(String resultsPath) throws Exception { - Path propsFile = tempDir.resolve("joularcodejava.properties"); - Properties props = new Properties(); - props.setProperty("results-path", resultsPath); - props.setProperty("stack-monitoring-sample-rate", "100"); - try (var w = Files.newBufferedWriter(propsFile, StandardCharsets.UTF_8)) { - props.store(w, null); - } - System.setProperty("joularcodejava.properties", propsFile.toString()); - AgentProperties agentProps = new AgentProperties(); - return new MonitoringHandler(agentProps, mockPowerSource, threadBean, osBean); - } - - /** - * Starts the handler on a daemon thread so that a test failure can never - * prevent the JVM from exiting. - */ - private void startHandler() { - handlerThread = new Thread(handler, "test-monitoring"); - handlerThread.setDaemon(true); - handlerThread.start(); - } - - // ------------------------------------------------------------------------- - // Startup lifecycle - // ------------------------------------------------------------------------- - - /** - * Under normal conditions (writable results directory, no-throw - * {@code PowerSource.initialize()}), the startup latch must be released - * within the timeout, {@code isStartupSuccessful()} must return {@code true}, - * and no failure must be recorded. - */ - @Test - void awaitStartup_successfulInit_latchReleasedAndSuccessful() throws Exception { - handler = makeHandler(tempDir.toString()); - startHandler(); - - boolean settled = handler.awaitStartup(3000); - assertTrue(settled, "awaitStartup should return true"); - assertTrue(handler.isStartupSuccessful(), "Startup should be successful"); - assertNull(handler.getStartupFailure()); - } - - /** - * When {@code PowerSource.initialize()} throws, the handler must: - *

    - *
  • release the startup latch (so {@code awaitStartup} does not hang),
  • - *
  • record {@code isStartupSuccessful() == false},
  • - *
  • expose the thrown exception via {@code getStartupFailure()}.
  • - *
- * The monitoring thread exits on its own after the failure; no explicit - * {@code stop()} call is needed (but the teardown calls it anyway as a guard). - */ - @Test - void awaitStartup_powerSourceThrows_latchReleasedWithFailure() throws Exception { - doThrow(new RuntimeException("init failed")).when(mockPowerSource).initialize(); - - handler = makeHandler(tempDir.toString()); - Logger logger = Logger.getLogger(MonitoringHandler.class.getName()); - Level oldLevel = logger.getLevel(); - logger.setLevel(Level.OFF); - boolean settled; - try { - startHandler(); - settled = handler.awaitStartup(3000); - } finally { - logger.setLevel(oldLevel); - } - assertTrue(settled); - assertFalse(handler.isStartupSuccessful()); - assertNotNull(handler.getStartupFailure()); - assertTrue(handler.getStartupFailure().getMessage().contains("init failed")); - } - - /** - * When the configured results path is an existing regular file rather than - * a directory, {@code ResultWriter.verifyWritable()} fails with an - * {@link java.io.IOException}. The handler must propagate this as a startup - * failure so the agent aborts with a clear log message rather than silently - * dropping all results. - */ - @Test - void awaitStartup_unwritableResultsPath_failsFast() throws Exception { - // A regular file at the path prevents directory creation. - Path blockingFile = tempDir.resolve("blocked"); - Files.writeString(blockingFile, "not a directory", StandardCharsets.UTF_8); - - handler = makeHandler(blockingFile.toString()); - Logger logger = Logger.getLogger(MonitoringHandler.class.getName()); - Level oldLevel = logger.getLevel(); - logger.setLevel(Level.OFF); - boolean settled; - try { - startHandler(); - settled = handler.awaitStartup(3000); - } finally { - logger.setLevel(oldLevel); - } - assertTrue(settled); - assertFalse(handler.isStartupSuccessful()); - assertNotNull(handler.getStartupFailure()); - assertInstanceOf(java.io.IOException.class, handler.getStartupFailure()); - } - - /** - * After a successful startup, {@code stop()} must interrupt the monitoring - * thread and {@code joinWithTimeout()} must return within the timeout, - * confirming the thread has terminated and will not prevent JVM shutdown. - */ - @Test - void stop_afterSuccessfulStartup_threadTerminates() throws Exception { - handler = makeHandler(tempDir.toString()); - startHandler(); - - handler.awaitStartup(3000); - assertTrue(handler.isStartupSuccessful()); - - handler.stop(); - handler.joinWithTimeout(3000); - - assertFalse(handlerThread.isAlive(), "Monitoring thread should have terminated"); - } - - // ------------------------------------------------------------------------- - // Pre-start state - // ------------------------------------------------------------------------- - - /** - * Before the monitoring thread is started, the startup latch has not been - * counted down yet, so {@code isStartupSuccessful()} must return its initial - * value of {@code false}. - */ - @Test - void isStartupSuccessful_beforeStart_returnsFalse() throws Exception { - handler = makeHandler(tempDir.toString()); - assertFalse(handler.isStartupSuccessful()); - } - - /** - * Before the monitoring thread is started, no failure has occurred, so - * {@code getStartupFailure()} must return {@code null}. - */ - @Test - void getStartupFailure_beforeStart_returnsNull() throws Exception { - handler = makeHandler(tempDir.toString()); - assertNull(handler.getStartupFailure()); - } - - /** - * {@code stop()} guards against a null {@code monitoringThread} field - * (set lazily when the thread starts). Calling it before the thread has - * been started must not throw a {@code NullPointerException}. - */ - @Test - void stop_beforeThreadStarted_doesNotThrow() throws Exception { - handler = makeHandler(tempDir.toString()); - assertDoesNotThrow(() -> handler.stop()); - } - - /** - * {@code joinWithTimeout()} guards against a null {@code monitoringThread} - * field and must return immediately (within well under 1 second) rather - * than blocking when no thread has been started. - */ - @Test - void joinWithTimeout_beforeThreadStarted_returnsImmediately() throws Exception { - handler = makeHandler(tempDir.toString()); - long start = System.currentTimeMillis(); - handler.joinWithTimeout(100); - long elapsed = System.currentTimeMillis() - start; - assertTrue(elapsed < 1000, "joinWithTimeout should return quickly when thread not started"); - } - - @Test - void constructor_lowSampleRate_logsWarning() throws Exception { - Path propsFile = tempDir.resolve("joularcodejava.properties"); - Properties props = new Properties(); - props.setProperty("results-path", tempDir.toString()); - props.setProperty("stack-monitoring-sample-rate", "1"); - try (var w = Files.newBufferedWriter(propsFile, StandardCharsets.UTF_8)) { - props.store(w, null); - } - System.setProperty("joularcodejava.properties", propsFile.toString()); - - Logger logger = Logger.getLogger(MonitoringHandler.class.getName()); - List records = new ArrayList<>(); - Handler capture = new Handler() { - @Override - public void publish(LogRecord record) { - records.add(record); - } - - @Override - public void flush() {} - - @Override - public void close() {} - }; - logger.addHandler(capture); - try { - handler = new MonitoringHandler(new AgentProperties(), mockPowerSource, threadBean, osBean); - } finally { - logger.removeHandler(capture); - } - - assertTrue(records.stream() - .anyMatch(record -> record.getMessage().contains("stack-monitoring-sample-rate"))); - } - - // ------------------------------------------------------------------------- - // Power attribution - // ------------------------------------------------------------------------- - - @Test - void calculateThreadPower_withCpuDeltas_usesCpuTimeProportions() { - Map power = MonitoringHandler.calculateThreadPower( - Map.of(1L, 25L, 2L, 75L), - 100.0, - 40.0); - - assertEquals(10.0, power.get(1L), 1e-9); - assertEquals(30.0, power.get(2L), 1e-9); - } - - @Test - void calculateThreadPower_withoutCpuDeltas_returnsEmptyMap() { - Map power = MonitoringHandler.calculateThreadPower( - Map.of(), - 0.0, - 80.0); - - assertTrue(power.isEmpty()); - } - - // ------------------------------------------------------------------------- - // attribute: which CPU time goes in the denominator, and what coverage says - // ------------------------------------------------------------------------- - - /** - * A thread that used CPU without ever being caught in a sample still belongs in the denominator. - * Leaving it out handed the whole of the JVM's power to the threads that were seen and so overstated every method on them. - */ - @Test - void attribute_unsampledThreadStaysInTheDenominator() { - Map sampled = Map.of(1L, 10); - Map start = Map.of(1L, 0L, 2L, 0L); - Map end = Map.of(1L, 300L, 2L, 700L); - - MonitoringHandler.Attribution attribution = MonitoringHandler.attribute(sampled, start, end); - - assertEquals(1000L, attribution.totalCpuDeltaNs(), "both threads' CPU time counts"); - assertEquals(Map.of(1L, 300L), attribution.sampledCpuDeltas(), "only the sampled one is paid"); - assertEquals(0.3, attribution.coverage(), 1e-9); - } - - /** With every CPU-using thread sampled, nothing is missing. */ - @Test - void attribute_everyThreadSampled_coverageIsOne() { - MonitoringHandler.Attribution attribution = MonitoringHandler.attribute( - Map.of(1L, 5, 2L, 5), Map.of(1L, 0L, 2L, 0L), Map.of(1L, 400L, 2L, 600L)); - - assertEquals(1.0, attribution.coverage(), 1e-9); - assertEquals(1000L, attribution.totalCpuDeltaNs()); - } - - /** - * A thread absent from the opening snapshot was started inside the window, so the whole of its - * counter was spent there. Thread ids are never reused, so this cannot pick up a predecessor's - * time. - */ - @Test - void attribute_threadStartedMidWindow_countsAllOfItsCpuTime() { - MonitoringHandler.Attribution attribution = MonitoringHandler.attribute( - Map.of(7L, 3), Map.of(), Map.of(7L, 500L)); - - assertEquals(500L, attribution.totalCpuDeltaNs()); - assertEquals(Map.of(7L, 500L), attribution.sampledCpuDeltas()); - assertEquals(1.0, attribution.coverage(), 1e-9); - } - - /** A thread whose counter did not move contributes nothing either way. */ - @Test - void attribute_idleThreadIgnored() { - MonitoringHandler.Attribution attribution = MonitoringHandler.attribute( - Map.of(1L, 4), Map.of(1L, 100L, 2L, 900L), Map.of(1L, 600L, 2L, 900L)); - - assertEquals(500L, attribution.totalCpuDeltaNs()); - assertEquals(1.0, attribution.coverage(), 1e-9, "the idle thread is not missing coverage"); - } - - /** No CPU used at all is not a division by zero. */ - @Test - void attribute_noCpuUsed_coverageIsZero() { - MonitoringHandler.Attribution attribution = MonitoringHandler.attribute( - Map.of(1L, 4), Map.of(1L, 100L), Map.of(1L, 100L)); - - assertEquals(0L, attribution.totalCpuDeltaNs()); - assertEquals(0.0, attribution.coverage(), 1e-9); - assertTrue(attribution.sampledCpuDeltas().isEmpty()); - } - - /** - * The point of the whole arrangement: what is handed out adds up to less than the JVM drew, by - * exactly the share that was never observed. The alternative, normalising over the sampled - * threads, would make the totals balance by inflating the methods that happened to be seen. - */ - @Test - void calculateThreadPower_unobservedShareIsLeftUnattributed() { - MonitoringHandler.Attribution attribution = MonitoringHandler.attribute( - Map.of(1L, 10), Map.of(1L, 0L, 2L, 0L), Map.of(1L, 250L, 2L, 750L)); - - Map power = MonitoringHandler.calculateThreadPower( - attribution.sampledCpuDeltas(), attribution.totalCpuDeltaNs(), 80.0); - - assertEquals(20.0, power.get(1L), 1e-9, "a quarter of the CPU time earns a quarter of the power"); - double handedOut = power.values().stream().mapToDouble(Double::doubleValue).sum(); - assertTrue(handedOut < 80.0, "the unsampled thread's 60 W is not shared out"); - assertEquals(attribution.coverage() * 80.0, handedOut, 1e-9, - "what is attributed is exactly the covered share"); - } -} diff --git a/src/test/java/org/noureddine/joular/joularcodejava/agent/PowerJoularRingBufferTest.java b/src/test/java/org/noureddine/joular/joularcodejava/agent/PowerJoularRingBufferTest.java new file mode 100644 index 0000000..b5c7fc1 --- /dev/null +++ b/src/test/java/org/noureddine/joular/joularcodejava/agent/PowerJoularRingBufferTest.java @@ -0,0 +1,231 @@ +/* + * Copyright (c) 2026, Adel Noureddine. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the + * GNU Lesser General Public License v3.0 (LGPL-3.0-only) + * which accompanies this distribution, and is available at + * https://www.gnu.org/licenses/lgpl-3.0.en.html + * + * Author : Adel Noureddine + */ + +package org.noureddine.joular.joularcodejava.agent; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.noureddine.joular.joularcodejava.agent.PowerJoularRingBuffer.ENTRY_SIZE; +import static org.noureddine.joular.joularcodejava.agent.PowerJoularRingBuffer.FILE_SIZE; +import static org.noureddine.joular.joularcodejava.agent.PowerJoularRingBuffer.MAX_WINDOW_NS; +import static org.noureddine.joular.joularcodejava.agent.PowerSource.ONE_SECOND_NS; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.DisabledOnOs; +import org.junit.jupiter.api.condition.OS; +import org.junit.jupiter.api.io.CleanupMode; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +/** + * The ring buffer read from files laid out the way PowerJoular writes them with {@code -r}. Entries carry the + * current time unless a test says otherwise, since old entries mean PowerJoular has stopped. + */ +class PowerJoularRingBufferTest { + + // A mapping is only released by the garbage collector, and Windows cannot delete a file while it is mapped + @TempDir(cleanup = CleanupMode.NEVER) + Path tempDir; + + private Path ring() { + return tempDir.resolve("powerjoular"); + } + + private static long now() { + return System.currentTimeMillis() / 1000L; + } + + /** Writes an area whose newest entry, {@code (head - 1) mod 5}, holds the given power. */ + private Path write(long head, double cpuPower, long timestamp) throws IOException { + ByteBuffer area = ByteBuffer.allocate(FILE_SIZE).order(ByteOrder.nativeOrder()); + area.putLong(0, head); + int entry = 8 + (int) Math.floorMod(head - 1, 5L) * ENTRY_SIZE; + area.putLong(entry, timestamp); + area.putDouble(entry + 8, cpuPower); + // Written in place, the way PowerJoular updates it: Windows refuses to truncate a mapped file + return Files.write(ring(), area.array(), StandardOpenOption.CREATE, StandardOpenOption.WRITE); + } + + private Path write(long head, double cpuPower) throws IOException { + return write(head, cpuPower, now()); + } + + /** Opens the source on an area at cycle {@code head - 1}, so that writing cycle {@code head} ends the window. */ + private PowerJoularRingBuffer openBefore(long head) throws IOException { + write(head - 1, 1.0); + return PowerJoularRingBuffer.open(ring()); + } + + // ------------------------------------------------------------------------- + // Reading + // ------------------------------------------------------------------------- + + @ParameterizedTest(name = "head={0} -> {1} W") + @CsvSource({"1, 25.0", "3, 18.0", "7, 11.5"}) + void watts_readsTheNewestEntry(long head, double power) throws IOException { + try (PowerJoularRingBuffer ring = openBefore(head)) { + write(head, power); + assertEquals(power, ring.watts(), 1e-9); + } + } + + /** No cycle written yet, the -1 PowerJoular writes for what it could not measure, and a value that is no power. */ + @ParameterizedTest(name = "head={0} power={1}") + @CsvSource({"0, 10.0", "-1, 10.0", "1, -1.0", "1, NaN"}) + void watts_nothingUsable_throws(long head, double power) throws IOException { + try (PowerJoularRingBuffer ring = openBefore(head)) { + write(head, power); + assertThrows(IOException.class, ring::watts); + } + } + + /** The area outlives PowerJoular, so it is the timestamp that tells a stopped one. A step back in time is not age. */ + @ParameterizedTest(name = "entry {0} s old -> read: {1}") + @CsvSource({"2, true", "-60, true", "3600, false"}) + void watts_judgesFreshnessOnTheTimestamp(long ageSeconds, boolean fresh) throws IOException { + try (PowerJoularRingBuffer ring = openBefore(2)) { + write(2, 16.0, now() - ageSeconds); + if (fresh) { + assertEquals(16.0, ring.watts(), 1e-9); + } else { + assertThrows(IOException.class, ring::watts); + } + } + } + + /** Each window is charged the cycle published during it, never the previous window's again. */ + @Test + void watts_noNewCycleSinceTheLastReading_throwsUntilTheNextOne() throws IOException { + try (PowerJoularRingBuffer ring = openBefore(5)) { + write(5, 13.0); + assertEquals(13.0, ring.watts(), 1e-9); + + assertThrows(IOException.class, ring::watts, "PowerJoular published nothing since"); + assertThrows(IOException.class, ring::watts, "mapped again, the same entry is not charged again"); + + write(6, 14.0); + assertEquals(14.0, ring.watts(), 1e-9); + } + } + + // ------------------------------------------------------------------------- + // Attaching + // ------------------------------------------------------------------------- + + /** PowerJoular may start after the application: the source opens anyway and picks the area up once it is there. */ + @Test + void watts_areaAppearsLater_isPickedUp() throws IOException { + try (PowerJoularRingBuffer ring = PowerJoularRingBuffer.open(ring())) { + assertThrows(IOException.class, ring::watts); + + write(1, 21.0); + assertEquals(21.0, ring.watts(), 1e-9); + } + } + + /** A file not yet sized to the area is not mapped: reading past its end would fault instead of failing. */ + @Test + void watts_areaTooShort_isNotMapped() throws IOException { + Files.write(ring(), new byte[64]); + try (PowerJoularRingBuffer ring = PowerJoularRingBuffer.open(ring())) { + assertThrows(IOException.class, ring::watts); + + write(1, 27.5); + assertEquals(27.5, ring.watts(), 1e-9); + } + } + + /** + * PowerJoular deletes the area and creates a new one when it restarts, which leaves the old one mapped here, no + * longer moving. The window without a cycle must lead to the new area, not to the old one's last value again. + */ + @Test + @DisabledOnOs(value = OS.WINDOWS, disabledReason = "Windows cannot delete a mapped file") + void watts_powerJoularRestarted_followsTheNewArea() throws IOException { + try (PowerJoularRingBuffer ring = openBefore(9)) { + Files.delete(ring()); + write(1, 35.0); + + assertThrows(IOException.class, ring::watts, "the old area published nothing"); + assertEquals(35.0, ring.watts(), 1e-9, "the new area is read"); + } + } + + @Test + @DisabledOnOs(OS.WINDOWS) + void watts_symbolicLink_isRefused() throws IOException { + Path elsewhere = tempDir.resolve("elsewhere"); + write(1, 20.0); + Files.move(ring(), elsewhere); + Files.createSymbolicLink(ring(), elsewhere); + + try (PowerJoularRingBuffer ring = PowerJoularRingBuffer.open(ring())) { + assertThrows(IOException.class, ring::watts); + } + } + + /** A pipe planted at the path would block the open, so only a plain file is mapped. */ + @Test + void watts_notARegularFile_isRefused() throws IOException { + Files.createDirectory(ring()); + + try (PowerJoularRingBuffer ring = PowerJoularRingBuffer.open(ring())) { + assertThrows(IOException.class, ring::watts); + } + } + + // ------------------------------------------------------------------------- + // Monitoring windows: ended by PowerJoular's cycles + // ------------------------------------------------------------------------- + + /** Without an area there is no cadence to follow, so windows last a second. */ + @Test + void windowOver_notAttached_afterOneSecond() { + try (PowerJoularRingBuffer ring = PowerJoularRingBuffer.open(ring())) { + assertFalse(ring.windowOver(ONE_SECOND_NS - 1)); + assertTrue(ring.windowOver(ONE_SECOND_NS)); + } + } + + /** The window waits for PowerJoular's next cycle, past a second if need be, and ends as soon as it is published. */ + @Test + void windowOver_endsWhenPowerJoularPublishes() throws IOException { + write(4, 12.0); + try (PowerJoularRingBuffer ring = PowerJoularRingBuffer.open(ring())) { + assertFalse(ring.windowOver(ONE_SECOND_NS), "no new cycle yet"); + + write(5, 13.0); + assertTrue(ring.windowOver(1), "a new cycle ends the window at once"); + + assertEquals(13.0, ring.watts(), 1e-9); + assertFalse(ring.windowOver(1), "the next window waits for the cycle after"); + } + } + + /** A PowerJoular that stopped publishing does not hold a window open forever. */ + @Test + void windowOver_noNewCycle_endsAfterTwoSeconds() throws IOException { + write(4, 12.0); + try (PowerJoularRingBuffer ring = PowerJoularRingBuffer.open(ring())) { + assertFalse(ring.windowOver(MAX_WINDOW_NS - 1)); + assertTrue(ring.windowOver(MAX_WINDOW_NS)); + } + } +} diff --git a/src/test/java/org/noureddine/joular/joularcodejava/agent/PowerModelTest.java b/src/test/java/org/noureddine/joular/joularcodejava/agent/PowerModelTest.java new file mode 100644 index 0000000..01fb499 --- /dev/null +++ b/src/test/java/org/noureddine/joular/joularcodejava/agent/PowerModelTest.java @@ -0,0 +1,168 @@ +/* + * Copyright (c) 2026, Adel Noureddine. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the + * GNU Lesser General Public License v3.0 (LGPL-3.0-only) + * which accompanies this distribution, and is available at + * https://www.gnu.org/licenses/lgpl-3.0.en.html + * + * Author : Adel Noureddine + */ + +package org.noureddine.joular.joularcodejava.agent; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.noureddine.joular.joularcodejava.agent.BranchTest.branch; + +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.junit.jupiter.api.Test; +import org.noureddine.joular.joularcodejava.agent.PowerModel.CpuUsage; + +class PowerModelTest { + + private static final long SECOND = 1_000_000_000L; + + // ------------------------------------------------------------------------- + // processShare: the JVM's part of the machine's CPU power + // ------------------------------------------------------------------------- + + /** Half of one core out of four is 12.5% of the machine; with the machine at 25%, the JVM is half of it. */ + @Test + void processShare_isTheJvmsLoadOverTheMachinesLoad() { + assertEquals(0.5, PowerModel.processShare(SECOND / 2, SECOND, 4, 0.25), 1e-9); + } + + /** The two loads are not taken at the exact same instant, so the ratio can go past 1. */ + @Test + void processShare_neverAboveOne() { + assertEquals(1.0, PowerModel.processShare(SECOND, SECOND, 4, 0.1), 1e-9); + } + + /** Without the machine's load, it is taken as fully loaded, which under-states the JVM's share. */ + @Test + void processShare_unknownSystemLoad_fallsBackToTheJvmsLoad() { + assertEquals(0.25, PowerModel.processShare(SECOND, SECOND, 4, -1.0), 1e-9); + assertEquals(0.25, PowerModel.processShare(SECOND, SECOND, 4, Double.NaN), 1e-9); + assertEquals(0.25, PowerModel.processShare(SECOND, SECOND, 4, 0.0), 1e-9); + } + + @Test + void processShare_nothingToDivide_isZero() { + assertEquals(0.0, PowerModel.processShare(0, SECOND, 4, 0.5)); + assertEquals(0.0, PowerModel.processShare(SECOND, 0, 4, 0.5)); + assertEquals(0.0, PowerModel.processShare(SECOND, SECOND, 0, 0.5)); + } + + // ------------------------------------------------------------------------- + // cpuUsage: which CPU time goes in the denominator, and what coverage says + // ------------------------------------------------------------------------- + + /** A thread that used CPU without being sampled still counts in the total, so the sampled ones are not overpaid. */ + @Test + void cpuUsage_unsampledThreadStaysInTheTotal() { + CpuUsage usage = PowerModel.cpuUsage(Set.of(1L), Map.of(1L, 0L, 2L, 0L), Map.of(1L, 300L, 2L, 700L)); + + assertEquals(1000L, usage.totalNs(), "both threads' CPU time counts"); + assertEquals(Map.of(1L, 300L), usage.sampledThreadsNs(), "only the sampled one is paid"); + assertEquals(0.3, usage.coverage(), 1e-9); + } + + /** A thread missing from the start snapshot began inside the window, so all its CPU time was spent there. */ + @Test + void cpuUsage_threadStartedMidWindow_countsAllOfItsCpuTime() { + CpuUsage usage = PowerModel.cpuUsage(Set.of(7L), Map.of(), Map.of(7L, 500L)); + + assertEquals(500L, usage.totalNs()); + assertEquals(1.0, usage.coverage(), 1e-9); + } + + @Test + void cpuUsage_idleThreadIgnored() { + CpuUsage usage = PowerModel.cpuUsage(Set.of(1L), Map.of(1L, 100L, 2L, 900L), Map.of(1L, 600L, 2L, 900L)); + + assertEquals(500L, usage.totalNs()); + assertEquals(1.0, usage.coverage(), 1e-9, "the idle thread is not missing coverage"); + } + + @Test + void cpuUsage_noCpuUsed_coverageIsZero() { + CpuUsage usage = PowerModel.cpuUsage(Set.of(1L), Map.of(1L, 100L), Map.of(1L, 100L)); + + assertEquals(0L, usage.totalNs()); + assertEquals(0.0, usage.coverage(), 1e-9); + assertTrue(usage.sampledThreadsNs().isEmpty()); + } + + // ------------------------------------------------------------------------- + // threadPower + // ------------------------------------------------------------------------- + + @Test + void threadPower_splitsByCpuTime() { + Map power = PowerModel.threadPower(new CpuUsage(Map.of(1L, 25L, 2L, 75L), 100L, 1.0), 40.0); + + assertEquals(10.0, power.get(1L), 1e-9); + assertEquals(30.0, power.get(2L), 1e-9); + } + + /** + * What is handed out is less than the JVM drew, by exactly the share never observed. Normalising over the + * sampled threads instead would balance the totals by inflating the methods that happened to be seen. + */ + @Test + void threadPower_unobservedShareIsLeftUnattributed() { + CpuUsage usage = PowerModel.cpuUsage(Set.of(1L), Map.of(1L, 0L, 2L, 0L), Map.of(1L, 250L, 2L, 750L)); + + Map power = PowerModel.threadPower(usage, 80.0); + + assertEquals(20.0, power.get(1L), 1e-9); + assertEquals(usage.coverage() * 80.0, power.values().stream().mapToDouble(Double::doubleValue).sum(), 1e-9); + } + + @Test + void threadPower_noPowerOrNoCpu_isEmpty() { + assertTrue(PowerModel.threadPower(new CpuUsage(Map.of(), 0L, 0.0), 80.0).isEmpty()); + assertTrue(PowerModel.threadPower(new CpuUsage(Map.of(1L, 10L), 10L, 1.0), 0.0).isEmpty()); + } + + // ------------------------------------------------------------------------- + // branchPower + // ------------------------------------------------------------------------- + + @Test + void branchPower_splitsEachThreadBySampleCount() { + Map power = PowerModel.branchPower( + Map.of(1L, Map.of(branch("a.A.a"), 3, branch("b.B.b"), 1)), Map.of(1L, 8.0), Branch::name); + + assertEquals(6.0, power.get("a.A.a"), 1e-9); + assertEquals(2.0, power.get("b.B.b"), 1e-9); + } + + @Test + void branchPower_sameBranchOnTwoThreads_isSummed() { + Map power = PowerModel.branchPower( + Map.of(1L, Map.of(branch("a.A.a"), 1), 2L, Map.of(branch("a.A.a"), 1)), + Map.of(1L, 2.0, 2L, 3.0), Branch::name); + + assertEquals(5.0, power.get("a.A.a"), 1e-9); + } + + /** Samples that matched no app frame still count for the thread, so their share is not given to the app branches. */ + @Test + void branchPower_unmatchedSamplesStayInTheThreadTotal() { + List prefixes = List.of("com.app"); + Map power = PowerModel.branchPower( + Map.of(1L, Map.of(branch("com.app.Main.run"), 1, branch("java.lang.Thread.run"), 3)), + Map.of(1L, 8.0), branch -> branch.appName(prefixes)); + + assertEquals(Map.of("com.app.Main.run", 2.0), power); + } + + @Test + void branchPower_threadWithoutPower_isSkipped() { + assertTrue(PowerModel.branchPower(Map.of(1L, Map.of(branch("a.A.a"), 1)), Map.of(), Branch::name).isEmpty()); + } +} diff --git a/src/test/java/org/noureddine/joular/joularcodejava/agent/PowerSourceTest.java b/src/test/java/org/noureddine/joular/joularcodejava/agent/PowerSourceTest.java new file mode 100644 index 0000000..0df11b2 --- /dev/null +++ b/src/test/java/org/noureddine/joular/joularcodejava/agent/PowerSourceTest.java @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2026, Adel Noureddine. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the + * GNU Lesser General Public License v3.0 (LGPL-3.0-only) + * which accompanies this distribution, and is available at + * https://www.gnu.org/licenses/lgpl-3.0.en.html + * + * Author : Adel Noureddine + */ + +package org.noureddine.joular.joularcodejava.agent; + +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assumptions.assumeFalse; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.DisabledOnOs; +import org.junit.jupiter.api.condition.OS; +import org.junit.jupiter.api.io.TempDir; + +/** Which source {@code power-source-type} opens. */ +class PowerSourceTest { + + @TempDir + Path tempDir; + + private Config config(String type, Path vmPowerFile, String vmPowerFormat) { + return new Config(type, tempDir.resolve("ring"), vmPowerFile, vmPowerFormat, 10, tempDir, List.of()); + } + + private Config config(String type) { + return config(type, null, "powerjoular"); + } + + @Test + void unknownType_throwsNamingTheChoices() { + IOException e = assertThrows(IOException.class, () -> PowerSource.open(config("joularcore"))); + assertTrue(e.getMessage().contains("auto, rapl, ringbuffer or vm"), e.getMessage()); + } + + /** Without RAPL, the CPU power comes from PowerJoular, which may start after the application. */ + @Test + void auto_withoutRapl_opensTheRingBuffer() throws IOException { + assumeFalse(LinuxRapl.isPresent()); + + try (PowerSource source = PowerSource.open(config("auto"))) { + assertInstanceOf(PowerJoularRingBuffer.class, source); + } + } + + @Test + void ringbuffer_opensEvenWithoutPowerJoular() throws IOException { + try (PowerSource source = PowerSource.open(config("ringbuffer"))) { + assertInstanceOf(PowerJoularRingBuffer.class, source); + } + } + + @Test + @DisabledOnOs(OS.LINUX) + void rapl_outsideLinux_throws() { + assertThrows(IOException.class, () -> PowerSource.open(config("rapl"))); + } + + /** The host may start writing the file after the application, so it does not have to be there yet. */ + @Test + void vm_opensEvenBeforeTheHostWrites() throws IOException { + try (PowerSource source = PowerSource.open(config("vm", tempDir.resolve("vm.csv"), "powerjoular"))) { + assertInstanceOf(VmPowerFile.class, source); + } + } + + @Test + void vm_withoutAFile_throwsNamingTheSetting() { + IOException e = assertThrows(IOException.class, () -> PowerSource.open(config("vm"))); + assertTrue(e.getMessage().contains("vm-power-file"), e.getMessage()); + } + + @Test + void vm_unknownFormat_throwsNamingTheChoices() { + IOException e = assertThrows(IOException.class, + () -> PowerSource.open(config("vm", tempDir.resolve("vm.csv"), "json"))); + assertTrue(e.getMessage().contains("powerjoular or watts"), e.getMessage()); + } +} diff --git a/src/test/java/org/noureddine/joular/joularcodejava/agent/ResultWriterTest.java b/src/test/java/org/noureddine/joular/joularcodejava/agent/ResultWriterTest.java new file mode 100644 index 0000000..f24d2de --- /dev/null +++ b/src/test/java/org/noureddine/joular/joularcodejava/agent/ResultWriterTest.java @@ -0,0 +1,163 @@ +/* + * Copyright (c) 2026, Adel Noureddine. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the + * GNU Lesser General Public License v3.0 (LGPL-3.0-only) + * which accompanies this distribution, and is available at + * https://www.gnu.org/licenses/lgpl-3.0.en.html + * + * Author : Adel Noureddine + */ + +package org.noureddine.joular.joularcodejava.agent; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.noureddine.joular.joularcodejava.agent.ResultWriter.ALL_METHODS_FILE; +import static org.noureddine.joular.joularcodejava.agent.ResultWriter.APP_METHODS_FILE; +import static org.noureddine.joular.joularcodejava.agent.ResultWriter.CSV_HEADER; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.DisabledOnOs; +import org.junit.jupiter.api.condition.OS; +import org.junit.jupiter.api.io.TempDir; + +class ResultWriterTest { + + @TempDir + Path tempDir; + + private List lines(String file) throws IOException { + return Files.readAllLines(tempDir.resolve(file), StandardCharsets.UTF_8); + } + + private void writeAll(Map power, double intervalSeconds) throws IOException { + try (ResultWriter writer = new ResultWriter(tempDir)) { + writer.write(power, Map.of(), 1000L, intervalSeconds, 1.0); + } + } + + // ------------------------------------------------------------------------- + // Opening + // ------------------------------------------------------------------------- + + @Test + void open_createsTheDirectoryAndBothFilesWithAHeader() throws IOException { + Path nested = tempDir.resolve("sub/nested"); + new ResultWriter(nested).close(); + + assertEquals(List.of(CSV_HEADER), Files.readAllLines(nested.resolve(ALL_METHODS_FILE))); + assertEquals(List.of(CSV_HEADER), Files.readAllLines(nested.resolve(APP_METHODS_FILE))); + } + + /** A regular file where the directory should be fails at startup, rather than dropping every result later. */ + @Test + void open_pathIsAFile_throws() throws IOException { + Path blockingFile = tempDir.resolve("blocked"); + Files.writeString(blockingFile, "not a directory"); + + assertThrows(IOException.class, () -> new ResultWriter(blockingFile)); + } + + /** A link planted where a results file goes must not make the agent, perhaps running as root, write through it. */ + @Test + @DisabledOnOs(OS.WINDOWS) + void open_symbolicLink_isRefused() throws IOException { + Path target = tempDir.resolve("target.txt"); + Files.writeString(target, "precious"); + Files.createSymbolicLink(tempDir.resolve(ALL_METHODS_FILE), target); + + assertThrows(IOException.class, () -> new ResultWriter(tempDir)); + assertEquals("precious", Files.readString(target)); + } + + /** Results already there are kept, and the header is not written twice. */ + @Test + void open_existingResults_appendsWithoutASecondHeader() throws IOException { + writeAll(Map.of("A.m", 1.0), 1.0); + writeAll(Map.of("B.m", 2.0), 1.0); + + List lines = lines(ALL_METHODS_FILE); + assertEquals(3, lines.size()); + assertEquals(1, lines.stream().filter(CSV_HEADER::equals).count()); + } + + // ------------------------------------------------------------------------- + // Rows + // ------------------------------------------------------------------------- + + @Test + void write_eachFileGetsItsOwnRows() throws IOException { + try (ResultWriter writer = new ResultWriter(tempDir)) { + writer.write(Map.of("java.lang.Thread.run;com.app.Main.run", 3.0), Map.of("com.app.Main.run", 3.0), + 1746000000000L, 1.0, 0.75); + } + + assertEquals("1746000000000,java.lang.Thread.run;com.app.Main.run,3.000000000,3.000000000,1.000000000,0.7500", + lines(ALL_METHODS_FILE).get(1)); + assertEquals("1746000000000,com.app.Main.run,3.000000000,3.000000000,1.000000000,0.7500", + lines(APP_METHODS_FILE).get(1)); + } + + @Test + void write_energyIsPowerTimesInterval() throws IOException { + writeAll(Map.of("m.m", 4.0), 2.5); + + String[] columns = lines(ALL_METHODS_FILE).get(1).split(","); + assertEquals(10.0, Double.parseDouble(columns[3]), 1e-9); + assertEquals(2.5, Double.parseDouble(columns[4]), 1e-9); + } + + @Test + void write_zeroOrNegativePower_rowSkipped() throws IOException { + Map power = new LinkedHashMap<>(); + power.put("A.zero", 0.0); + power.put("B.negative", -1.0); + power.put("C.positive", 3.0); + writeAll(power, 1.0); + + List lines = lines(ALL_METHODS_FILE); + assertEquals(2, lines.size()); + assertTrue(lines.get(1).contains("C.positive")); + } + + @Test + void write_branchWithAComma_isQuoted() throws IOException { + writeAll(Map.of("foo,bar", 3.0), 1.0); + + assertTrue(lines(ALL_METHODS_FILE).get(1).contains("\"foo,bar\"")); + } + + @Test + void close_isIdempotent() throws IOException { + ResultWriter writer = new ResultWriter(tempDir); + assertDoesNotThrow(() -> { + writer.close(); + writer.close(); + }); + } + + // ------------------------------------------------------------------------- + // csvEscape + // ------------------------------------------------------------------------- + + @Test + void csvEscape() { + assertEquals("org.example.Foo.bar", ResultWriter.csvEscape("org.example.Foo.bar")); + assertEquals("", ResultWriter.csvEscape("")); + assertEquals("\"a,b\"", ResultWriter.csvEscape("a,b")); + assertEquals("\"foo\"\"bar\"", ResultWriter.csvEscape("foo\"bar")); + assertEquals("\"a\nb\"", ResultWriter.csvEscape("a\nb")); + assertEquals("\"a\rb\"", ResultWriter.csvEscape("a\rb")); + assertEquals("\"a,\"\"b\"\"\"", ResultWriter.csvEscape("a,\"b\"")); + } +} diff --git a/src/test/java/org/noureddine/joular/joularcodejava/agent/ThreadSamplerTest.java b/src/test/java/org/noureddine/joular/joularcodejava/agent/ThreadSamplerTest.java new file mode 100644 index 0000000..e8a500b --- /dev/null +++ b/src/test/java/org/noureddine/joular/joularcodejava/agent/ThreadSamplerTest.java @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2026, Adel Noureddine. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the + * GNU Lesser General Public License v3.0 (LGPL-3.0-only) + * which accompanies this distribution, and is available at + * https://www.gnu.org/licenses/lgpl-3.0.en.html + * + * Author : Adel Noureddine + */ + +package org.noureddine.joular.joularcodejava.agent; + +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 com.sun.management.ThreadMXBean; +import java.lang.management.ManagementFactory; +import org.junit.jupiter.api.Test; +import org.noureddine.joular.joularcodejava.agent.ThreadSampler.Samples; + +/** Sampling this very JVM, whose test thread is running while it is sampled. */ +class ThreadSamplerTest { + + private final ThreadMXBean threadBean = (ThreadMXBean) ManagementFactory.getThreadMXBean(); + + @Test + void sample_recordsTheRunningTestThread() { + ThreadSampler sampler = new ThreadSampler(threadBean, -1); + Samples samples = new Samples(); + + sampler.sample(samples); + + assertEquals(1, samples.taken); + assertTrue(samples.branches.containsKey(Thread.currentThread().threadId())); + assertTrue(sampler.cpuTimes().containsKey(Thread.currentThread().threadId())); + } + + /** The agent's own thread is never measured, so it is left out of both the samples and the CPU times. */ + @Test + void ownThread_isLeftOut() { + long self = Thread.currentThread().threadId(); + ThreadSampler sampler = new ThreadSampler(threadBean, self); + Samples samples = new Samples(); + + sampler.sample(samples); + + assertFalse(samples.branches.containsKey(self)); + assertFalse(sampler.cpuTimes().containsKey(self)); + assertTrue(sampler.ownCpuTime() >= 0, "its time is read apart"); + } +} diff --git a/src/test/java/org/noureddine/joular/joularcodejava/agent/VmPowerFileTest.java b/src/test/java/org/noureddine/joular/joularcodejava/agent/VmPowerFileTest.java new file mode 100644 index 0000000..2ac1d92 --- /dev/null +++ b/src/test/java/org/noureddine/joular/joularcodejava/agent/VmPowerFileTest.java @@ -0,0 +1,160 @@ +/* + * Copyright (c) 2026, Adel Noureddine. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the + * GNU Lesser General Public License v3.0 (LGPL-3.0-only) + * which accompanies this distribution, and is available at + * https://www.gnu.org/licenses/lgpl-3.0.en.html + * + * Author : Adel Noureddine + */ + +package org.noureddine.joular.joularcodejava.agent; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.noureddine.joular.joularcodejava.agent.VmPowerFile.MAX_REUSES; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +/** The file a host writes the power of a virtual machine to, as the guest reads it. */ +class VmPowerFileTest { + + @TempDir + Path tempDir; + + private Path file() { + return tempDir.resolve("vm-power.csv"); + } + + private void write(String content) throws IOException { + Files.writeString(file(), content); + } + + /** The row PowerJoular rewrites every second with -o for the process a virtual machine runs as. */ + @Nested + class PowerJoularFormat { + + private VmPowerFile open() throws IOException { + return VmPowerFile.open(file(), "powerjoular"); + } + + @Test + void readsThePowerColumn() throws IOException { + write("1790000000,0.2500,12.5000\n"); + assertEquals(12.5, open().watts(), 1e-9); + } + + /** The host may run Windows, and only the first line is the latest row. */ + @Test + void windowsLineEndingsAndFurtherLines_areFine() throws IOException { + write("1790000000,0.25,12.5\r\nsomething else\r\n"); + assertEquals(12.5, open().watts(), 1e-9); + } + + /** A row caught while the host writes it may be cut inside the number, which would still parse. */ + @Test + void rowWithoutItsNewline_isNotRead() throws IOException { + write("1790000000,0.25,12.5"); + assertThrows(IOException.class, open()::watts); + } + + @Test + void wholeHostFile_isRefused() throws IOException { + write("1790000000,0.25,40.0,35.0,5.0\n"); + IOException e = assertThrows(IOException.class, open()::watts); + assertTrue(e.getMessage().contains("whole host"), e.getMessage()); + } + + /** Written with -f instead of -o, the file starts with a header. */ + @ParameterizedTest + @ValueSource(strings = {"Timestamp,CPU Usage,CPU Power\n", "1790000000,0.25,-1.0\n", "1790000000,0.25\n", "\n"}) + void unusableRow_throws(String content) throws IOException { + write(content); + assertThrows(IOException.class, open()::watts); + } + + @Test + void missingFile_throws() { + IOException e = assertThrows(IOException.class, () -> open().watts()); + assertTrue(e.getMessage().contains("There is no"), e.getMessage()); + } + + @Test + void notARegularFile_isRefused() throws IOException { + Files.createDirectory(file()); + assertThrows(IOException.class, open()::watts); + } + + /** + * The host and the windows run on separate one-second cycles, so a window may see the row the previous one + * saw, or catch the file empty while it is rewritten. The last row covers that, for a little while. + */ + @Test + void noNewRow_lastOneCoversAFewWindowsThenTheHostIsTakenToHaveStopped() throws IOException { + VmPowerFile source = open(); + write("1790000000,0.25,12.5\n"); + assertEquals(12.5, source.watts(), 1e-9); + + write(""); + assertEquals(12.5, source.watts(), 1e-9, "caught while the host rewrites it"); + write("1790000000,0.25,12.5\n"); + for (int reuse = 2; reuse <= MAX_REUSES; reuse++) { + assertEquals(12.5, source.watts(), 1e-9, "the same row again"); + } + + IOException e = assertThrows(IOException.class, source::watts); + assertTrue(e.getMessage().contains("has not updated"), e.getMessage()); + + write("1790000004,0.30,15.0\n"); + assertEquals(15.0, source.watts(), 1e-9, "the host writes again"); + } + } + + /** A power alone, written by anything. */ + @Nested + class WattsFormat { + + private VmPowerFile open() throws IOException { + return VmPowerFile.open(file(), "watts"); + } + + @ParameterizedTest + @ValueSource(strings = {"42.5\n", "42.5", " 42.5 \r\n"}) + void readsTheValue(String content) throws IOException { + write(content); + assertEquals(42.5, open().watts(), 1e-9); + } + + /** There is no timestamp to tell a stopped host from a steady one, so the value is used as long as it is there. */ + @Test + void unchangedValue_isUsedEveryTime() throws IOException { + write("42.5\n"); + VmPowerFile source = open(); + for (int window = 0; window <= MAX_REUSES + 2; window++) { + assertEquals(42.5, source.watts(), 1e-9); + } + } + + @ParameterizedTest + @ValueSource(strings = {"-1\n", "lots\n", "NaN\n", ""}) + void unusableValue_throws(String content) throws IOException { + write(content); + assertThrows(IOException.class, open()::watts); + } + + @Test + void lineTooLong_throws() throws IOException { + write("4".repeat(300)); + assertThrows(IOException.class, open()::watts); + } + } +} diff --git a/src/test/java/org/noureddine/joular/joularcodejava/agent/result/ResultWriterTest.java b/src/test/java/org/noureddine/joular/joularcodejava/agent/result/ResultWriterTest.java deleted file mode 100644 index b8addfe..0000000 --- a/src/test/java/org/noureddine/joular/joularcodejava/agent/result/ResultWriterTest.java +++ /dev/null @@ -1,352 +0,0 @@ -/* - * Copyright (c) 2026, Adel Noureddine. - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the - * GNU Lesser General Public License v3.0 (LGPL-3.0-only) - * which accompanies this distribution, and is available at - * https://www.gnu.org/licenses/lgpl-3.0.en.html - * - * Author : Adel Noureddine - */ - -package org.noureddine.joular.joularcodejava.agent.result; - -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; - -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; - -import static org.junit.jupiter.api.Assertions.*; - -/** - * Unit tests for {@link ResultWriter}. - * - *

Covers three areas: - *

    - *
  • {@code csvEscape} — RFC 4180 escaping of special characters in CSV field values.
  • - *
  • {@code verifyWritable} — fail-fast directory creation and write-probe behaviour.
  • - *
  • {@code writeRuntimeMethods} — CSV output format, header injection, power filtering, - * energy calculation, and idempotent close.
  • - *
- * - *

Every test that touches the filesystem uses a JUnit 5 {@code @TempDir} so no - * cleanup is needed and tests are fully isolated from each other. - */ -class ResultWriterTest { - - @TempDir - Path tempDir; - - // ------------------------------------------------------------------------- - // csvEscape - // ------------------------------------------------------------------------- - - /** - * A {@code null} input must be treated as an empty string rather than - * propagating a NullPointerException into the CSV output. - */ - @Test - void csvEscape_null_returnsEmptyString() { - assertEquals("", ResultWriter.csvEscape(null)); - } - - /** - * A value that contains none of the RFC 4180 special characters - * (comma, double-quote, newline, carriage-return) must be returned - * unchanged — no surrounding quotes should be added. - */ - @Test - void csvEscape_plainText_unchanged() { - String input = "org.example.Foo.bar"; - assertEquals(input, ResultWriter.csvEscape(input)); - } - - /** - * An empty string contains no special characters and must be returned as-is. - */ - @Test - void csvEscape_empty_returnsEmpty() { - assertEquals("", ResultWriter.csvEscape("")); - } - - /** - * A comma inside a field value is a CSV delimiter and must be neutralised - * by wrapping the entire value in double-quotes. - */ - @Test - void csvEscape_withComma_quotesValue() { - assertEquals("\"a,b\"", ResultWriter.csvEscape("a,b")); - } - - /** - * A double-quote inside a field value must be escaped by doubling it - * (RFC 4180 §2.7) and the whole value wrapped in double-quotes. - * Input {@code foo"bar} becomes {@code "foo""bar"}. - */ - @Test - void csvEscape_withDoubleQuote_doublesAndQuotes() { - assertEquals("\"foo\"\"bar\"", ResultWriter.csvEscape("foo\"bar")); - } - - /** - * A newline character inside a value causes the value to be wrapped in - * double-quotes; the newline itself must be preserved verbatim inside the - * quotes (not escaped or removed). - */ - @Test - void csvEscape_withNewline_quotesValue() { - String result = ResultWriter.csvEscape("a\nb"); - assertTrue(result.startsWith("\"") && result.endsWith("\""), "Should be quoted: " + result); - assertTrue(result.contains("\n"), "Newline should be preserved inside quotes"); - } - - /** - * A carriage-return character must trigger quoting just as a newline does. - */ - @Test - void csvEscape_withCarriageReturn_quotesValue() { - String result = ResultWriter.csvEscape("a\rb"); - assertTrue(result.startsWith("\"") && result.endsWith("\""), "Should be quoted: " + result); - } - - /** - * When a value contains both a comma and a double-quote, both escaping - * rules apply simultaneously: the value is wrapped in double-quotes, and - * each internal double-quote is doubled. - * Input {@code a,"b"} becomes {@code "a,""b""}. - */ - @Test - void csvEscape_withCommaAndQuote_bothRulesApplied() { - // "a,"b"" → "a,""b""" - assertEquals("\"a,\"\"b\"\"\"", ResultWriter.csvEscape("a,\"b\"")); - } - - // ------------------------------------------------------------------------- - // verifyWritable - // ------------------------------------------------------------------------- - - /** - * When the results directory does not yet exist, {@code verifyWritable} - * must create it (including any missing intermediate directories) and - * complete without throwing. - */ - @Test - void verifyWritable_nonExistentDir_createsDir() throws Exception { - Path nested = tempDir.resolve("sub/nested"); - ResultWriter writer = new ResultWriter(nested.toString()); - assertDoesNotThrow(writer::verifyWritable); - assertTrue(Files.isDirectory(nested)); - } - - /** - * When the results directory already exists and is writable, - * {@code verifyWritable} must succeed without throwing. - */ - @Test - void verifyWritable_existingDir_succeeds() throws Exception { - ResultWriter writer = new ResultWriter(tempDir.toString()); - assertDoesNotThrow(writer::verifyWritable); - } - - /** - * The write-probe file ({@code .joular-write-probe}) is used only to - * confirm the directory is writable at startup. It must be deleted - * before {@code verifyWritable} returns so it does not pollute the - * results directory. - */ - @Test - void verifyWritable_probeFileDeletedAfterCheck() throws Exception { - ResultWriter writer = new ResultWriter(tempDir.toString()); - writer.verifyWritable(); - assertFalse(Files.exists(tempDir.resolve(".joular-write-probe"))); - } - - // ------------------------------------------------------------------------- - // writeRuntimeMethods - // ------------------------------------------------------------------------- - - /** - * The first write to a new CSV file must produce the standard header line - * as the very first line of the file, followed by the data row. - * Expected header: {@code timestamp,branch,power_watts,energy_joules,interval_seconds}. - */ - @Test - void writeRuntimeMethods_newFile_writesHeaderThenData() throws Exception { - ResultWriter writer = new ResultWriter(tempDir.toString()); - writer.verifyWritable(); - writer.writeRuntimeMethods(Map.of("com.A.method", 5.0), 1000L, 1.0, 1.0, "out.csv"); - writer.close(); - - List lines = Files.readAllLines(tempDir.resolve("out.csv"), StandardCharsets.UTF_8); - assertEquals("timestamp,branch,power_watts,energy_joules,interval_seconds,coverage", lines.get(0)); - assertEquals(2, lines.size()); - } - - /** - * A method entry with exactly zero power must not produce a CSV row. - * Only entries with strictly positive power are meaningful for energy - * attribution; writing zeros would inflate the output file without adding - * information. - */ - @Test - void writeRuntimeMethods_zeroPower_rowSkipped() throws Exception { - ResultWriter writer = new ResultWriter(tempDir.toString()); - writer.verifyWritable(); - - // Use LinkedHashMap to guarantee insertion order for the assertion below. - Map map = new LinkedHashMap<>(); - map.put("A.zero", 0.0); - map.put("B.positive", 3.0); - writer.writeRuntimeMethods(map, 1000L, 1.0, 1.0, "out.csv"); - writer.close(); - - List lines = Files.readAllLines(tempDir.resolve("out.csv"), StandardCharsets.UTF_8); - assertEquals(2, lines.size(), "Expected header + 1 data row"); - assertTrue(lines.get(1).contains("B.positive")); - } - - /** - * A method entry with negative power must be suppressed for the same - * reason as zero power — the power filter is {@code powerWatts > 0}. - */ - @Test - void writeRuntimeMethods_negativePower_rowSkipped() throws Exception { - ResultWriter writer = new ResultWriter(tempDir.toString()); - writer.verifyWritable(); - - Map map = new LinkedHashMap<>(); - map.put("A.neg", -1.0); - map.put("B.pos", 2.0); - writer.writeRuntimeMethods(map, 1000L, 1.0, 1.0, "out.csv"); - writer.close(); - - List lines = Files.readAllLines(tempDir.resolve("out.csv"), StandardCharsets.UTF_8); - assertEquals(2, lines.size()); - assertFalse(lines.get(1).contains("A.neg")); - } - - /** - * The {@code energy_joules} column must equal {@code power_watts × interval_seconds}. - * This test uses power = 4.0 W and interval = 2.5 s, so the expected energy is 10.0 J. - * Values are read back from the written file and compared with a tolerance of 1e-9 - * to accommodate any floating-point rounding in the nine-decimal-place format. - */ - @Test - void writeRuntimeMethods_energyEqualsPoworTimesInterval() throws Exception { - ResultWriter writer = new ResultWriter(tempDir.toString()); - writer.verifyWritable(); - writer.writeRuntimeMethods(Map.of("m.method", 4.0), 1000L, 2.5, 1.0, "out.csv"); - writer.close(); - - List lines = Files.readAllLines(tempDir.resolve("out.csv"), StandardCharsets.UTF_8); - String dataLine = lines.get(1); - String[] cols = dataLine.split(","); - // CSV columns: timestamp, branch, power_watts, energy_joules, interval_seconds - double power = Double.parseDouble(cols[2]); - double energy = Double.parseDouble(cols[3]); - double interval = Double.parseDouble(cols[4]); - assertEquals(power * interval, energy, 1e-9); - assertEquals(4.0, power, 1e-9); - assertEquals(10.0, energy, 1e-9); - } - - /** - * Power and energy values must be formatted with exactly nine decimal places - * (format string {@code "%.9f"}) to preserve sub-milliwatt precision for - * fine-grained energy attribution. - */ - @Test - void writeRuntimeMethods_usesNineDecimalPrecision() throws Exception { - ResultWriter writer = new ResultWriter(tempDir.toString()); - writer.verifyWritable(); - // 1/3 is an irrational decimal — ensures the formatter actually rounds at 9 places. - writer.writeRuntimeMethods(Map.of("m.m", 1.0 / 3.0), 1000L, 1.0, 1.0, "out.csv"); - writer.close(); - - List lines = Files.readAllLines(tempDir.resolve("out.csv"), StandardCharsets.UTF_8); - String dataLine = lines.get(1); - String[] cols = dataLine.split(","); - // power_watts is column index 2 - assertTrue(cols[2].matches("-?\\d+\\.\\d{9}"), "Expected 9 decimal places, got: " + cols[2]); - } - - /** - * When {@code writeRuntimeMethods} is called multiple times on the same - * file name, subsequent calls must append rows without writing the header - * a second time. The header must appear exactly once at the top of the file. - */ - @Test - void writeRuntimeMethods_appendsWithoutDuplicateHeader() throws Exception { - ResultWriter writer = new ResultWriter(tempDir.toString()); - writer.verifyWritable(); - writer.writeRuntimeMethods(Map.of("A.m", 1.0), 1000L, 1.0, 1.0, "out.csv"); - writer.writeRuntimeMethods(Map.of("B.m", 2.0), 2000L, 1.0, 1.0, "out.csv"); - writer.close(); - - List lines = Files.readAllLines(tempDir.resolve("out.csv"), StandardCharsets.UTF_8); - long headerCount = lines.stream() - .filter(l -> l.startsWith("timestamp,branch")) - .count(); - assertEquals(1, headerCount, "Header should appear exactly once"); - assertEquals(3, lines.size(), "Expected header + 2 data rows"); - } - - /** - * If the output file already exists but is empty (zero bytes), a fresh - * header must be prepended before the first data row. This covers the - * edge case where the file was created externally or left empty by a - * previous failed run. - */ - @Test - void writeRuntimeMethods_addsHeaderToEmptyPreexistingFile() throws Exception { - // Pre-create an empty file to exercise the "size == 0" branch in the writer. - Path outFile = tempDir.resolve("out.csv"); - Files.write(outFile, new byte[0]); - - ResultWriter writer = new ResultWriter(tempDir.toString()); - writer.verifyWritable(); - writer.writeRuntimeMethods(Map.of("X.m", 5.0), 1000L, 1.0, 1.0, "out.csv"); - writer.close(); - - List lines = Files.readAllLines(outFile, StandardCharsets.UTF_8); - assertEquals("timestamp,branch,power_watts,energy_joules,interval_seconds,coverage", lines.get(0)); - } - - /** - * Method (branch) names used as CSV field values are passed through - * {@code csvEscape}. A branch name that contains a comma must be wrapped - * in double-quotes so it reads back as a single field, not two separate - * columns. - */ - @Test - void writeRuntimeMethods_csvEscapeAppliedToKey() throws Exception { - ResultWriter writer = new ResultWriter(tempDir.toString()); - writer.verifyWritable(); - writer.writeRuntimeMethods(Map.of("foo,bar", 3.0), 1000L, 1.0, 1.0, "out.csv"); - writer.close(); - - String content = Files.readString(tempDir.resolve("out.csv"), StandardCharsets.UTF_8); - assertTrue(content.contains("\"foo,bar\""), "Comma in key should be CSV-escaped"); - } - - /** - * Calling {@code close()} more than once must not throw. The internal - * writer map is cleared on the first call, so the second call must - * silently do nothing. - */ - @Test - void close_isIdempotent() throws Exception { - ResultWriter writer = new ResultWriter(tempDir.toString()); - writer.verifyWritable(); - writer.writeRuntimeMethods(Map.of("A.m", 1.0), 1000L, 1.0, 1.0, "out.csv"); - assertDoesNotThrow(() -> { - writer.close(); - writer.close(); - }); - } -} diff --git a/src/test/java/org/noureddine/joular/joularcodejava/agent/source/LinuxRaplPowerSourceTest.java b/src/test/java/org/noureddine/joular/joularcodejava/agent/source/LinuxRaplPowerSourceTest.java deleted file mode 100644 index 0d281af..0000000 --- a/src/test/java/org/noureddine/joular/joularcodejava/agent/source/LinuxRaplPowerSourceTest.java +++ /dev/null @@ -1,261 +0,0 @@ -/* - * Copyright (c) 2026, Adel Noureddine. - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the - * GNU Lesser General Public License v3.0 (LGPL-3.0-only) - * which accompanies this distribution, and is available at - * https://www.gnu.org/licenses/lgpl-3.0.en.html - * - * Author : Adel Noureddine - */ - -package org.noureddine.joular.joularcodejava.agent.source; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; - -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.time.Clock; -import java.time.Duration; -import java.time.Instant; -import java.time.ZoneId; -import java.time.ZoneOffset; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; - -class LinuxRaplPowerSourceTest { - - @TempDir - Path tempDir; - - private final MutableClock clock = new MutableClock(Instant.parse("2026-01-01T00:00:00Z")); - - private Path writeRaplPackage(String name, String energyMicrojoules, String maxMicrojoules) throws Exception { - Path pkg = tempDir.resolve("intel-rapl-0"); - Files.createDirectories(pkg); - Files.writeString(pkg.resolve("name"), name + "\n", StandardCharsets.UTF_8); - Files.writeString(pkg.resolve("energy_uj"), energyMicrojoules + "\n", StandardCharsets.UTF_8); - Files.writeString(pkg.resolve("max_energy_range_uj"), maxMicrojoules + "\n", StandardCharsets.UTF_8); - return pkg; - } - - private void writeEnergy(Path pkg, String energyMicrojoules) throws Exception { - Files.writeString(pkg.resolve("energy_uj"), energyMicrojoules + "\n", StandardCharsets.UTF_8); - } - - private LinuxRaplPowerSource sourceFor(Path pkg) { - return new LinuxRaplPowerSource(pkg, "Linux", clock); - } - - @Test - void getCurrentPower_validPackage_calculatesPowerFromEnergyDelta() throws Exception { - Path pkg = writeRaplPackage("package-0", "1000000", "10000000"); - LinuxRaplPowerSource source = sourceFor(pkg); - source.initialize(); - - writeEnergy(pkg, "3000000"); - clock.advance(Duration.ofSeconds(2)); - - assertEquals(1.0, source.getCurrentPower(), 1e-9); - } - - @Test - void getCurrentPower_seededInitialEnergyDoesNotBecomePower() throws Exception { - Path pkg = writeRaplPackage("package-0", "1000000", "10000000"); - LinuxRaplPowerSource source = sourceFor(pkg); - source.initialize(); - - clock.advance(Duration.ofSeconds(1)); - - assertEquals(0.0, source.getCurrentPower(), 1e-9); - } - - @Test - void getCurrentPower_counterWrap_usesMaxEnergyRange() throws Exception { - Path pkg = writeRaplPackage("package-0", "9000000", "10000000"); - LinuxRaplPowerSource source = sourceFor(pkg); - source.initialize(); - - writeEnergy(pkg, "1000000"); - clock.advance(Duration.ofSeconds(2)); - - assertEquals(1.0, source.getCurrentPower(), 1e-9); - } - - @Test - void initialize_nonLinux_throwsException() throws Exception { - Path pkg = writeRaplPackage("package-0", "1000000", "10000000"); - LinuxRaplPowerSource source = new LinuxRaplPowerSource(pkg, "Windows 11", clock); - - assertThrows(Exception.class, source::initialize); - } - - @Test - void initialize_wrongPackageDomain_throwsException() throws Exception { - Path pkg = writeRaplPackage("core", "1000000", "10000000"); - LinuxRaplPowerSource source = sourceFor(pkg); - - assertThrows(Exception.class, source::initialize); - } - - @Test - void initialize_missingEnergyFile_throwsException() throws Exception { - Path pkg = tempDir.resolve("intel-rapl-0"); - Files.createDirectories(pkg); - Files.writeString(pkg.resolve("name"), "package-0\n", StandardCharsets.UTF_8); - Files.writeString(pkg.resolve("max_energy_range_uj"), "10000000\n", StandardCharsets.UTF_8); - LinuxRaplPowerSource source = sourceFor(pkg); - - assertThrows(Exception.class, source::initialize); - } - - @Test - void initialize_nonRegularMaxFile_throwsException() throws Exception { - Path pkg = tempDir.resolve("intel-rapl-0"); - Files.createDirectories(pkg); - Files.writeString(pkg.resolve("name"), "package-0\n", StandardCharsets.UTF_8); - Files.writeString(pkg.resolve("energy_uj"), "1000000\n", StandardCharsets.UTF_8); - Files.createDirectory(pkg.resolve("max_energy_range_uj")); - LinuxRaplPowerSource source = sourceFor(pkg); - - assertThrows(Exception.class, source::initialize); - } - - @Test - void initialize_malformedMaxEnergyRange_throwsException() throws Exception { - Path pkg = writeRaplPackage("package-0", "1000000", "not-a-number"); - LinuxRaplPowerSource source = sourceFor(pkg); - - assertThrows(Exception.class, source::initialize); - } - - @Test - void initialize_impossibleInitialEnergy_throwsException() throws Exception { - Path pkg = writeRaplPackage("package-0", "11000000", "10000000"); - LinuxRaplPowerSource source = sourceFor(pkg); - - assertThrows(Exception.class, source::initialize); - } - - @Test - void getCurrentPower_parseFailure_returnsLastKnownPower() throws Exception { - Path pkg = writeRaplPackage("package-0", "1000000", "10000000"); - LinuxRaplPowerSource source = sourceFor(pkg); - source.initialize(); - writeEnergy(pkg, "3000000"); - clock.advance(Duration.ofSeconds(1)); - assertEquals(2.0, source.getCurrentPower(), 1e-9); - - writeEnergy(pkg, "not-a-number"); - clock.advance(Duration.ofSeconds(1)); - - assertEquals(2.0, source.getCurrentPower(), 1e-9); - } - - @Test - void getCurrentPower_negativeEnergy_returnsLastKnownPower() throws Exception { - Path pkg = writeRaplPackage("package-0", "1000000", "10000000"); - LinuxRaplPowerSource source = sourceFor(pkg); - source.initialize(); - writeEnergy(pkg, "2000000"); - clock.advance(Duration.ofSeconds(1)); - assertEquals(1.0, source.getCurrentPower(), 1e-9); - - writeEnergy(pkg, "-1"); - clock.advance(Duration.ofSeconds(1)); - - assertEquals(1.0, source.getCurrentPower(), 1e-9); - } - - @Test - void getCurrentPower_nonFiniteEnergy_returnsLastKnownPower() throws Exception { - Path pkg = writeRaplPackage("package-0", "1000000", "10000000"); - LinuxRaplPowerSource source = sourceFor(pkg); - source.initialize(); - writeEnergy(pkg, "2000000"); - clock.advance(Duration.ofSeconds(1)); - assertEquals(1.0, source.getCurrentPower(), 1e-9); - - writeEnergy(pkg, "NaN"); - clock.advance(Duration.ofSeconds(1)); - - assertEquals(1.0, source.getCurrentPower(), 1e-9); - } - - @Test - void getCurrentPower_energyAboveMaxRange_returnsLastKnownPower() throws Exception { - Path pkg = writeRaplPackage("package-0", "1000000", "10000000"); - LinuxRaplPowerSource source = sourceFor(pkg); - source.initialize(); - writeEnergy(pkg, "2000000"); - clock.advance(Duration.ofSeconds(1)); - assertEquals(1.0, source.getCurrentPower(), 1e-9); - - writeEnergy(pkg, "11000000"); - clock.advance(Duration.ofSeconds(1)); - - assertEquals(1.0, source.getCurrentPower(), 1e-9); - } - - @Test - void getCurrentPower_clockDoesNotAdvance_returnsLastKnownPowerWithoutUpdatingState() throws Exception { - Path pkg = writeRaplPackage("package-0", "1000000", "10000000"); - LinuxRaplPowerSource source = sourceFor(pkg); - source.initialize(); - - writeEnergy(pkg, "2000000"); - assertEquals(0.0, source.getCurrentPower(), 1e-9); - - clock.advance(Duration.ofSeconds(1)); - assertEquals(1.0, source.getCurrentPower(), 1e-9); - } - - @Test - void close_resetsSourceState() throws Exception { - Path pkg = writeRaplPackage("package-0", "1000000", "10000000"); - LinuxRaplPowerSource source = sourceFor(pkg); - source.initialize(); - writeEnergy(pkg, "2000000"); - clock.advance(Duration.ofSeconds(1)); - assertEquals(1.0, source.getCurrentPower(), 1e-9); - - source.close(); - - assertEquals(0.0, source.getCurrentPower(), 1e-9); - } - - private static final class MutableClock extends Clock { - private Instant instant; - private final ZoneId zone; - - private MutableClock(Instant instant) { - this(instant, ZoneOffset.UTC); - } - - private MutableClock(Instant instant, ZoneId zone) { - this.instant = instant; - this.zone = zone; - } - - private void advance(Duration duration) { - instant = instant.plus(duration); - } - - @Override - public ZoneId getZone() { - return zone; - } - - @Override - public Clock withZone(ZoneId zone) { - return new MutableClock(instant, zone); - } - - @Override - public Instant instant() { - return instant; - } - } -} diff --git a/src/test/java/org/noureddine/joular/joularcodejava/agent/source/PowerJoularCsvSourceTest.java b/src/test/java/org/noureddine/joular/joularcodejava/agent/source/PowerJoularCsvSourceTest.java deleted file mode 100644 index 2ed0159..0000000 --- a/src/test/java/org/noureddine/joular/joularcodejava/agent/source/PowerJoularCsvSourceTest.java +++ /dev/null @@ -1,300 +0,0 @@ -/* - * Copyright (c) 2026, Adel Noureddine. - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the - * GNU Lesser General Public License v3.0 (LGPL-3.0-only) - * which accompanies this distribution, and is available at - * https://www.gnu.org/licenses/lgpl-3.0.en.html - * - * Author : Adel Noureddine - */ - -package org.noureddine.joular.joularcodejava.agent.source; - -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.CsvSource; -import org.junit.jupiter.params.provider.ValueSource; - -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; - -import static org.junit.jupiter.api.Assertions.*; -import static org.noureddine.joular.joularcodejava.agent.source.PowerJoularCsvSource.SYSTEM_CPU_POWER_INDEX; -import static org.noureddine.joular.joularcodejava.agent.source.PowerSource.MAX_STALE_CYCLES; -import static org.noureddine.joular.joularcodejava.agent.source.TestLogging.quietly; - -/** - * Unit tests for {@link PowerJoularCsvSource}. - * - *

Fixtures are real PowerJoular rows. The system file it writes with {@code -f} or {@code -o} is - * {@code Timestamp,CPU Usage,Total Power,CPU Power,GPU Power}, so CPU Power sits at index 3 and not - * at index 1, which is CPU Usage. - * - *

Timestamps are written as the current time: the source ignores rows older than ten seconds, so - * a fixed timestamp would make every file look like one left behind by a PowerJoular that stopped. - */ -class PowerJoularCsvSourceTest { - - private static final String SYSTEM_HEADER = "Timestamp,CPU Usage,Total Power,CPU Power,GPU Power"; - private static final String TARGET_HEADER = "Timestamp,CPU Usage,CPU Power"; - - @TempDir - Path tempDir; - - private static long now() { - return System.currentTimeMillis() / 1000L; - } - - /** One system row with the given CPU Power and a current timestamp. */ - private static String systemRow(double cpuPower) { - return now() + ",0.2460,18.4500," + cpuPower + ",3.2500"; - } - - /** One row of the file PowerJoular writes for a monitored process. */ - private static String targetRow(double cpuPower) { - return now() + ",0.0310," + cpuPower; - } - - private Path writeCsv(String content) throws Exception { - Path csv = tempDir.resolve("powerjoular.csv"); - Files.writeString(csv, content, StandardCharsets.UTF_8); - return csv; - } - - private static Double parseSystemRow(String row) { - return PowerJoularCsvSource.parseCpuPower(row.split(",", -1), SYSTEM_CPU_POWER_INDEX); - } - - // ------------------------------------------------------------------------- - // parseCpuPower - // ------------------------------------------------------------------------- - - /** - * A well formed system row must give back the value in the CPU Power column, not another one. - * Whitespace around the value is stripped, and zero is a real reading: a CPU that was read and - * drew no measurable power. - */ - @ParameterizedTest(name = "{0} -> {1} W") - @CsvSource(delimiter = '|', value = { - "1756681930,0.2460,18.4500,15.2000,3.2500 | 15.2", - "1756681930,0.0,0.0,0.0,0.0 | 0.0", - "1756681930,0.2,18.4, 7.77 ,3.2 | 7.77", - }) - void parseCpuPower_validRow_returnsCpuPower(String row, double expected) { - assertEquals(expected, parseSystemRow(row), 1e-9); - } - - /** - * Values that are not a power must come back as null rather than throw. PowerJoular writes -1 for - * a value it could not read at all, which is not the same as zero; Infinity and NaN both parse - * successfully in Java, so they need rejecting explicitly; and a row can be corrupted or half - * written. - */ - @ParameterizedTest - @ValueSource(strings = { - "1756681930,0.2460,18.4500,-1.0000,3.2500", // unreadable sentinel - "1756681930,0.2,18.4,Infinity,3.2", - "1756681930,0.2,18.4,NaN,3.2", - "1756681930,0.2,18.4,abc,3.2", - "1756681930,0.2,18.4,,3.2", // empty column - "1756681930,0.2", // row shorter than the index - }) - void parseCpuPower_unusableRow_returnsNull(String row) { - assertNull(parseSystemRow(row)); - } - - /** A column index of -1 stands for a column that could not be worked out. */ - @Test - void parseCpuPower_unresolvedColumn_returnsNull() { - assertNull(PowerJoularCsvSource.parseCpuPower("1756681930,0.2,18.4,15.2,3.2".split(",", -1), -1)); - } - - // ------------------------------------------------------------------------- - // parseTimestamp - // ------------------------------------------------------------------------- - - /** A data row opens with a time, whole or fractional. */ - @ParameterizedTest - @CsvSource(delimiter = '|', value = { - "1756681930 | 1756681930", - "1756681930.5 | 1756681930.5", - " 1756681930 | 1756681930", - }) - void parseTimestamp_numericFirstColumn_returnsTime(String firstColumn, double expected) { - assertEquals(expected, PowerJoularCsvSource.parseTimestamp(firstColumn), 1e-9); - } - - /** Anything that is not a time marks the header row, a comment, or an empty column. */ - @ParameterizedTest - @ValueSource(strings = {"Timestamp", "header", "", " "}) - void parseTimestamp_nonNumericFirstColumn_returnsNull(String firstColumn) { - assertNull(PowerJoularCsvSource.parseTimestamp(firstColumn)); - } - - // ------------------------------------------------------------------------- - // getCurrentPower - // ------------------------------------------------------------------------- - - /** A file that is not there yet must give zero and a warning, not an exception. */ - @Test - void getCurrentPower_missingFile_returnsZero() throws Exception { - PowerJoularCsvSource source = new PowerJoularCsvSource( - tempDir.resolve("nonexistent.csv").toString()); - quietly(PowerJoularCsvSource.class, () -> assertEquals(0.0, source.getCurrentPower(), 1e-9)); - } - - /** The file PowerJoular writes with -f: a header, then rows. */ - @Test - void getCurrentPower_systemFileWithHeader_returnsCpuPower() throws Exception { - Path csv = writeCsv(SYSTEM_HEADER + "\n" + systemRow(15.2) + "\n"); - - PowerJoularCsvSource source = new PowerJoularCsvSource(csv.toString()); - assertEquals(15.2, source.getCurrentPower(), 1e-9); - } - - /** - * The file PowerJoular writes with -o: the latest row only, and no header. The column is worked - * out from the number of columns, which is what makes both forms readable. - */ - @Test - void getCurrentPower_headerlessSystemFile_returnsCpuPower() throws Exception { - Path csv = writeCsv(systemRow(12.5) + "\n"); - - PowerJoularCsvSource source = new PowerJoularCsvSource(csv.toString()); - assertEquals(12.5, source.getCurrentPower(), 1e-9); - } - - /** - * The three column file holds the power of one monitored process, which the agent would scale by - * the share of the machine the JVM is using a second time. It must be refused, not read. - */ - @Test - void getCurrentPower_targetFile_isRejected() throws Exception { - Path csv = writeCsv(targetRow(1.84) + "\n"); - - PowerJoularCsvSource source = new PowerJoularCsvSource(csv.toString()); - quietly(PowerJoularCsvSource.class, () -> assertEquals(0.0, source.getCurrentPower(), 1e-9, - "The per-process file must be refused rather than scaled twice")); - } - - /** - * The same refusal when the source sees the header before any row has been written, which is what - * {@code powerjoular -f} does: it creates the file with its header and adds the first row a second - * later. Reading the header first must not teach the source a column and so talk it past the - * refusal on the following cycle. - */ - @Test - void getCurrentPower_targetFileHeaderSeenFirst_isStillRejected() throws Exception { - Path csv = writeCsv(TARGET_HEADER + "\n"); - - PowerJoularCsvSource source = new PowerJoularCsvSource(csv.toString()); - quietly(PowerJoularCsvSource.class, () -> { - assertEquals(0.0, source.getCurrentPower(), 1e-9, "A header on its own carries no power"); - - Files.writeString(csv, TARGET_HEADER + "\n" + targetRow(1.84) + "\n", StandardCharsets.UTF_8); - assertEquals(0.0, source.getCurrentPower(), 1e-9, - "The row must still be refused once it arrives"); - }); - } - - /** A file holding only its header has nothing to read yet. */ - @Test - void getCurrentPower_headerOnlyFile_returnsZero() throws Exception { - Path csv = writeCsv(SYSTEM_HEADER + "\n"); - - PowerJoularCsvSource source = new PowerJoularCsvSource(csv.toString()); - assertEquals(0.0, source.getCurrentPower(), 1e-9); - } - - /** With several rows, the last one is the newest measurement. */ - @Test - void getCurrentPower_multipleRows_returnsLastRow() throws Exception { - Path csv = writeCsv(SYSTEM_HEADER + "\n" - + systemRow(5.0) + "\n" - + systemRow(12.0) + "\n" - + systemRow(18.0) + "\n"); - - PowerJoularCsvSource source = new PowerJoularCsvSource(csv.toString()); - assertEquals(18.0, source.getCurrentPower(), 1e-9); - } - - /** - * A file left behind by a PowerJoular that has stopped still parses, so it is the timestamp that - * has to rule it out. - */ - @Test - void getCurrentPower_staleRow_returnsZero() throws Exception { - Path csv = writeCsv(SYSTEM_HEADER + "\n" + (now() - 3600) + ",0.2460,18.4500,15.2000,3.2500\n"); - - PowerJoularCsvSource source = new PowerJoularCsvSource(csv.toString()); - quietly(PowerJoularCsvSource.class, () -> assertEquals(0.0, source.getCurrentPower(), 1e-9, - "A row an hour old must not be taken for a current measurement")); - } - - /** A row that cannot be parsed is covered by the last value we trust. */ - @Test - void getCurrentPower_parseFailure_returnsLastKnownPower() throws Exception { - Path csv = writeCsv(SYSTEM_HEADER + "\n" + systemRow(7.0) + "\n"); - PowerJoularCsvSource source = new PowerJoularCsvSource(csv.toString()); - assertEquals(7.0, source.getCurrentPower(), 1e-9); - - Files.writeString(csv, SYSTEM_HEADER + "\n" + now() + ",0.2460,18.4500,not-a-number,3.2500\n", - StandardCharsets.UTF_8); - assertEquals(7.0, source.getCurrentPower(), 1e-9, - "A single unreadable cycle must be covered by the last known power"); - } - - /** In overwrite mode PowerJoular empties the file before writing, so a read can land on nothing. */ - @Test - void getCurrentPower_emptyFile_returnsLastKnownPower() throws Exception { - Path csv = writeCsv(systemRow(9.5) + "\n"); - PowerJoularCsvSource source = new PowerJoularCsvSource(csv.toString()); - assertEquals(9.5, source.getCurrentPower(), 1e-9); - - Files.writeString(csv, "", StandardCharsets.UTF_8); - assertEquals(9.5, source.getCurrentPower(), 1e-9); - } - - /** - * The cover the last known value gives is bounded: once PowerJoular has been quiet for long - * enough, the source reports zero rather than going on replaying a value that is no longer true. - */ - @Test - void getCurrentPower_afterGraceWindow_returnsZero() throws Exception { - Path csv = writeCsv(SYSTEM_HEADER + "\n" + systemRow(20.0) + "\n"); - PowerJoularCsvSource source = new PowerJoularCsvSource(csv.toString()); - assertEquals(20.0, source.getCurrentPower(), 1e-9); - - Files.delete(csv); - - quietly(PowerJoularCsvSource.class, () -> { - for (int cycle = 1; cycle <= MAX_STALE_CYCLES; cycle++) { - assertEquals(20.0, source.getCurrentPower(), 1e-9, - "Cycle " + cycle + " is still within the grace window"); - } - assertEquals(0.0, source.getCurrentPower(), 1e-9, - "Past the grace window the source must stop attributing energy"); - }); - } - - /** A producer that comes back must be picked up again, with the stale state cleared. */ - @Test - void getCurrentPower_producerReturns_recovers() throws Exception { - Path csv = writeCsv(SYSTEM_HEADER + "\n" + systemRow(20.0) + "\n"); - PowerJoularCsvSource source = new PowerJoularCsvSource(csv.toString()); - assertEquals(20.0, source.getCurrentPower(), 1e-9); - - Files.delete(csv); - quietly(PowerJoularCsvSource.class, () -> { - for (int cycle = 0; cycle <= MAX_STALE_CYCLES; cycle++) { - source.getCurrentPower(); - } - }); - - Files.writeString(csv, SYSTEM_HEADER + "\n" + systemRow(33.0) + "\n", StandardCharsets.UTF_8); - assertEquals(33.0, source.getCurrentPower(), 1e-9); - } -} diff --git a/src/test/java/org/noureddine/joular/joularcodejava/agent/source/PowerJoularRingBufferSourceTest.java b/src/test/java/org/noureddine/joular/joularcodejava/agent/source/PowerJoularRingBufferSourceTest.java deleted file mode 100644 index eb69876..0000000 --- a/src/test/java/org/noureddine/joular/joularcodejava/agent/source/PowerJoularRingBufferSourceTest.java +++ /dev/null @@ -1,348 +0,0 @@ -/* - * Copyright (c) 2026, Adel Noureddine. - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the - * GNU Lesser General Public License v3.0 (LGPL-3.0-only) - * which accompanies this distribution, and is available at - * https://www.gnu.org/licenses/lgpl-3.0.en.html - * - * Author : Adel Noureddine - */ - -package org.noureddine.joular.joularcodejava.agent.source; - -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.CsvSource; - -import java.nio.ByteBuffer; -import java.nio.ByteOrder; -import java.nio.file.Files; -import java.nio.file.Path; - -import static org.junit.jupiter.api.Assertions.*; -import static org.noureddine.joular.joularcodejava.agent.source.PowerJoularRingBufferSource.ENTRY_SIZE; -import static org.noureddine.joular.joularcodejava.agent.source.PowerJoularRingBufferSource.FILE_SIZE; -import static org.noureddine.joular.joularcodejava.agent.source.PowerJoularRingBufferSource.MAX_WINDOW_NANOS; -import static org.noureddine.joular.joularcodejava.agent.source.PowerSource.DEFAULT_WINDOW_NANOS; -import static org.noureddine.joular.joularcodejava.agent.source.PowerSource.MAX_STALE_CYCLES; -import static org.noureddine.joular.joularcodejava.agent.source.TestLogging.quietly; - -/** - * Unit tests for {@link PowerJoularRingBufferSource}. - * - *

Fixtures reproduce the area PowerJoular writes with {@code -r}, in the byte order of the - * machine: - * - *

- *   Offset 0      : u64  head
- *   Offset 8      : entry[0]  (48 bytes)
- *   Offset 56     : entry[1]
- *   ...
- *   Each entry:
- *     +0  u64  timestamp     (Unix time in seconds)
- *     +8  f64  cpu_power     (watts)
- *     +16 f64  gpu_power
- *     +24 f64  total_power
- *     +32 f64  cpu_usage
- *     +40 f64  pid_app_power
- *
- *   Total size: 8 + 5 * 48 = 248 bytes.
- * 
- * - *

The sizes come from the class under test rather than from copies, so that a change to the layout - * cannot leave the fixtures quietly asserting against the old one. - * - *

Entries carry the current time: the source ignores entries older than ten seconds, so a fixed - * timestamp would make every fixture look like an area left behind by a PowerJoular that stopped. - */ -class PowerJoularRingBufferSourceTest { - - @TempDir - Path tempDir; - - private static long now() { - return System.currentTimeMillis() / 1000L; - } - - /** An area holding one entry with the current time. */ - private Path writeTempRingBuffer(long head, int entryIdx, double cpuPower) throws Exception { - return writeTempRingBuffer(head, entryIdx, cpuPower, now()); - } - - private Path writeTempRingBuffer(long head, int entryIdx, double cpuPower, long timestamp) - throws Exception { - ByteBuffer buf = ByteBuffer.allocate(FILE_SIZE).order(ByteOrder.nativeOrder()); - buf.putLong(0, head); - int entryOffset = 8 + entryIdx * ENTRY_SIZE; - buf.putLong(entryOffset, timestamp); - buf.putDouble(entryOffset + 8, cpuPower); - Path file = tempDir.resolve("ring.bin"); - Files.write(file, buf.array()); - return file; - } - - private PowerJoularRingBufferSource sourceFor(Path file) { - PowerJoularRingBufferSource source = new PowerJoularRingBufferSource(file.toString()); - source.initialize(); - return source; - } - - // ------------------------------------------------------------------------- - // Reading an entry - // ------------------------------------------------------------------------- - - /** - * The counter says how many cycles have been written, so the newest entry is {@code (head-1) mod 5} - * and its cpu_power sits 8 bytes into it. A counter of zero or below means PowerJoular has not - * written a cycle yet, and -1 is what it writes for a value it could not read at all. - */ - @ParameterizedTest(name = "head={0} entry={1} cpu_power={2} -> {3} W") - @CsvSource({ - // head, entryIdx, cpuPower, expected - " 1, 0, 25.0, 25.0", // first cycle - " 3, 2, 18.0, 18.0", // (3-1) % 5 = 2 - " 7, 1, 11.5, 11.5", // the counter wraps round the ring - " 0, 0, 99.0, 0.0", // nothing written yet - " -1, 0, 10.0, 0.0", // a counter outside its range - " 1, 0, -1.0, 0.0", // the unreadable sentinel - " 1, 0, NaN, 0.0", // not a finite power - }) - void getCurrentPower_readsNewestEntry(long head, int entryIdx, double cpuPower, double expected) - throws Exception { - PowerJoularRingBufferSource source = sourceFor(writeTempRingBuffer(head, entryIdx, cpuPower)); - assertEquals(expected, source.getCurrentPower(), 1e-9); - } - - /** Reading the same unchanged area twice gives the same value. */ - @Test - void getCurrentPower_calledTwice_returnsSameValue() throws Exception { - PowerJoularRingBufferSource source = sourceFor(writeTempRingBuffer(1L, 0, 30.0)); - assertEquals(30.0, source.getCurrentPower(), 1e-9); - assertEquals(30.0, source.getCurrentPower(), 1e-9); - } - - // ------------------------------------------------------------------------- - // Freshness - // ------------------------------------------------------------------------- - - /** - * The area outlives PowerJoular on purpose, so an old one still holds a plausible counter and a - * plausible power. It is the timestamp that has to rule it out. A timestamp ahead of us means the - * clock stepped, which is not staleness. - */ - @ParameterizedTest(name = "entry {0}s old -> {1} W") - @CsvSource({ - " 2, 16.0", // just read - " -60, 16.0", // the clock stepped backwards - " 3600, 0.0", // an hour old: PowerJoular has stopped - }) - void getCurrentPower_judgesFreshnessOnTheTimestamp(long ageSeconds, double expected) - throws Exception { - Path file = writeTempRingBuffer(1L, 0, 16.0, now() - ageSeconds); - PowerJoularRingBufferSource source = sourceFor(file); - quietly(PowerJoularRingBufferSource.class, - () -> assertEquals(expected, source.getCurrentPower(), 1e-9)); - } - - // ------------------------------------------------------------------------- - // Attaching - // ------------------------------------------------------------------------- - - /** Reading before initialize must not throw. */ - @Test - void getCurrentPower_neverInitialized_returnsZero() throws Exception { - PowerJoularRingBufferSource source = new PowerJoularRingBufferSource( - tempDir.resolve("nonexistent").toString()); - quietly(PowerJoularRingBufferSource.class, - () -> assertEquals(0.0, source.getCurrentPower(), 1e-9)); - } - - /** - * A missing area is not fatal: PowerJoular may not have started yet, so the agent must come up - * rather than abort, and must pick the area up once it appears. - */ - @Test - void getCurrentPower_fileAppearsLater_attaches() throws Exception { - Path file = tempDir.resolve("ring.bin"); - PowerJoularRingBufferSource source = new PowerJoularRingBufferSource(file.toString()); - quietly(PowerJoularRingBufferSource.class, () -> { - assertDoesNotThrow(source::initialize); - assertEquals(0.0, source.getCurrentPower(), 1e-9); - }); - - writeTempRingBuffer(1L, 0, 21.0); - assertEquals(21.0, source.getCurrentPower(), 1e-9); - } - - /** - * A file shorter than the area is one PowerJoular has created but not yet sized. Mapping past its - * end would fault on access rather than fail at the mapping, so the length is checked first, and - * the area is read once it grows. - */ - @Test - void getCurrentPower_shortFileThenSized_attaches() throws Exception { - Path file = tempDir.resolve("ring.bin"); - Files.write(file, new byte[64]); - - PowerJoularRingBufferSource source = new PowerJoularRingBufferSource(file.toString()); - quietly(PowerJoularRingBufferSource.class, () -> { - source.initialize(); - assertEquals(0.0, source.getCurrentPower(), 1e-9); - }); - - writeTempRingBuffer(1L, 0, 27.5); - assertEquals(27.5, source.getCurrentPower(), 1e-9); - } - - // ------------------------------------------------------------------------- - // The grace window - // ------------------------------------------------------------------------- - - /** - * The cover the last known value gives is bounded: once PowerJoular has been quiet for long - * enough, the source reports zero rather than going on replaying a value that is no longer true. - */ - @Test - void getCurrentPower_afterGraceWindow_returnsZero() throws Exception { - long now = now(); - PowerJoularRingBufferSource source = sourceFor(writeTempRingBuffer(1L, 0, 20.0, now)); - assertEquals(20.0, source.getCurrentPower(), 1e-9); - - // The same area, with its entry aged past the limit: PowerJoular has stopped writing. - writeTempRingBuffer(1L, 0, 20.0, now - 3600); - - quietly(PowerJoularRingBufferSource.class, () -> { - for (int cycle = 1; cycle <= MAX_STALE_CYCLES; cycle++) { - assertEquals(20.0, source.getCurrentPower(), 1e-9, - "Cycle " + cycle + " is still within the grace window"); - } - assertEquals(0.0, source.getCurrentPower(), 1e-9, - "Past the grace window the source must stop attributing energy"); - }); - } - - /** A producer that comes back must be picked up again, with the stale state cleared. */ - @Test - void getCurrentPower_producerReturns_recovers() throws Exception { - long now = now(); - PowerJoularRingBufferSource source = sourceFor(writeTempRingBuffer(1L, 0, 20.0, now)); - assertEquals(20.0, source.getCurrentPower(), 1e-9); - - writeTempRingBuffer(1L, 0, 20.0, now - 3600); - quietly(PowerJoularRingBufferSource.class, () -> { - for (int cycle = 0; cycle <= MAX_STALE_CYCLES; cycle++) { - source.getCurrentPower(); - } - }); - - writeTempRingBuffer(2L, 1, 35.0); - assertEquals(35.0, source.getCurrentPower(), 1e-9); - } - - // ------------------------------------------------------------------------- - // Closing - // ------------------------------------------------------------------------- - - /** Reading after close must not throw. */ - @Test - void close_thenGetCurrentPower_returnsZero() throws Exception { - PowerJoularRingBufferSource source = sourceFor(writeTempRingBuffer(1L, 0, 20.0)); - assertEquals(20.0, source.getCurrentPower(), 1e-9); - - source.close(); - // Closing drops the mapping; the next read re-attaches, which is harmless. - quietly(PowerJoularRingBufferSource.class, () -> assertDoesNotThrow(source::getCurrentPower)); - } - - /** The contract on {@link PowerSource} says close must be safe even if initialize never ran. */ - @Test - void close_withoutInitialize_isSafe() { - PowerJoularRingBufferSource source = new PowerJoularRingBufferSource("/nonexistent"); - assertDoesNotThrow(source::close); - } - - // ------------------------------------------------------------------------- - // isWindowComplete: the edge the agent closes its attribution window on - // ------------------------------------------------------------------------- - - /** Unattached, there is no cadence to follow, so windows are simply the default length. */ - @Test - void isWindowComplete_notAttached_usesTheFixedWindow() throws Exception { - PowerJoularRingBufferSource source = new PowerJoularRingBufferSource( - tempDir.resolve("nonexistent").toString()); - quietly(PowerJoularRingBufferSource.class, () -> { - source.initialize(); - source.beginWindow(); - assertFalse(source.isWindowComplete(DEFAULT_WINDOW_NANOS - 1)); - assertTrue(source.isWindowComplete(DEFAULT_WINDOW_NANOS)); - }); - } - - /** - * An area that exists but holds no cycle yet is PowerJoular still starting up, not a stall. - * The window runs its default length and nothing is warned about. - */ - @Test - void isWindowComplete_noCycleWrittenYet_usesTheFixedWindowQuietly() throws Exception { - PowerJoularRingBufferSource source = sourceFor(writeTempRingBuffer(0L, 0, 0.0)); - source.beginWindow(); - - assertFalse(source.isWindowComplete(DEFAULT_WINDOW_NANOS - 1)); - assertTrue(source.isWindowComplete(DEFAULT_WINDOW_NANOS)); - assertTrue(source.isWindowComplete(MAX_WINDOW_NANOS), - "waiting past the cap must not be treated as a stall before the first cycle"); - } - - /** The window stays open until PowerJoular publishes, then closes on that edge. */ - @Test - void isWindowComplete_closesOnTheProducersEdge() throws Exception { - PowerJoularRingBufferSource source = sourceFor(writeTempRingBuffer(4L, 3, 12.0)); - source.beginWindow(); - - assertFalse(source.isWindowComplete(DEFAULT_WINDOW_NANOS), - "past a second but the producer has not published, so keep waiting for its edge"); - - writeTempRingBuffer(5L, 4, 13.0); - assertTrue(source.isWindowComplete(1L), "a new cycle closes the window immediately"); - } - - /** - * A producer that has stopped must not stretch window after window to the cap. - * The first overrun is reported, and windows fall back to the fixed length until it publishes again. - */ - @Test - void isWindowComplete_stalledProducer_warnsOnceThenUsesTheFixedWindow() throws Exception { - PowerJoularRingBufferSource source = sourceFor(writeTempRingBuffer(4L, 3, 12.0)); - - quietly(PowerJoularRingBufferSource.class, () -> { - source.beginWindow(); - assertTrue(source.isWindowComplete(MAX_WINDOW_NANOS), "the cap ends the stalled wait"); - - // Every window after it keeps the plain cadence instead of waiting out the cap again - source.beginWindow(); - assertTrue(source.isWindowComplete(DEFAULT_WINDOW_NANOS)); - }); - } - - /** Once the producer comes back, windows realign to its edge. */ - @Test - void isWindowComplete_producerRecovers_realignsToItsEdge() throws Exception { - PowerJoularRingBufferSource source = sourceFor(writeTempRingBuffer(4L, 3, 12.0)); - - quietly(PowerJoularRingBufferSource.class, () -> { - source.beginWindow(); - assertTrue(source.isWindowComplete(MAX_WINDOW_NANOS)); - - // The producer publishes again part way through the next window - source.beginWindow(); - writeTempRingBuffer(5L, 4, 13.0); - assertTrue(source.isWindowComplete(1L), "the edge is followed again"); - - // Back to waiting for the producer rather than settling for the fixed length - source.beginWindow(); - assertFalse(source.isWindowComplete(DEFAULT_WINDOW_NANOS)); - }); - } -} diff --git a/src/test/java/org/noureddine/joular/joularcodejava/agent/source/PowerSourceFactoryTest.java b/src/test/java/org/noureddine/joular/joularcodejava/agent/source/PowerSourceFactoryTest.java deleted file mode 100644 index b623e08..0000000 --- a/src/test/java/org/noureddine/joular/joularcodejava/agent/source/PowerSourceFactoryTest.java +++ /dev/null @@ -1,142 +0,0 @@ -/* - * Copyright (c) 2026, Adel Noureddine. - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the - * GNU Lesser General Public License v3.0 (LGPL-3.0-only) - * which accompanies this distribution, and is available at - * https://www.gnu.org/licenses/lgpl-3.0.en.html - * - * Author : Adel Noureddine - */ - -package org.noureddine.joular.joularcodejava.agent.source; - -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; -import org.noureddine.joular.joularcodejava.agent.utils.AgentProperties; - -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; - -import static org.junit.jupiter.api.Assertions.*; - -/** - * Unit tests for {@link PowerSourceFactory}. - * - *

The factory reads {@code power-source-type} from {@link AgentProperties} - * and constructs the corresponding {@link PowerSource} implementation. Each test - * writes a minimal properties file to a {@code @TempDir} and points the system - * property {@code joularcodejava.properties} at it so that {@link AgentProperties} - * picks up the desired configuration. - * - *

System-property isolation

- *

The system property is cleared in {@code @AfterEach} to prevent any test - * from leaking configuration state into subsequent tests, regardless of execution - * order. - * - *

AgentProperties default-fallback caveat

- *

When a property value is empty or whitespace-only, {@link AgentProperties} - * returns the built-in default rather than an empty string. Tests that exercise - * error paths therefore use an explicitly invalid (non-empty) value rather than - * trying to pass an empty one. - */ -class PowerSourceFactoryTest { - - @TempDir - Path tempDir; - - private Path propsFile; - - @BeforeEach - void setUp() throws Exception { - propsFile = tempDir.resolve("joularcodejava.properties"); - } - - @AfterEach - void tearDown() { - System.clearProperty("joularcodejava.properties"); - } - - /** - * Writes {@code content} to the temp properties file and sets the system - * property so that {@link AgentProperties} loads it. - */ - private AgentProperties propsWithContent(String content) throws Exception { - Files.writeString(propsFile, content, StandardCharsets.UTF_8); - System.setProperty("joularcodejava.properties", propsFile.toString()); - return new AgentProperties(); - } - - // ------------------------------------------------------------------------- - // Successful dispatch - // ------------------------------------------------------------------------- - - /** - * {@code power-source-type=csv} must produce a {@link PowerJoularCsvSource} - * instance. The CSV path falls back to the default value; no further - * configuration is needed. - */ - @Test - void getPowerSource_csv_returnsCSVSource() throws Exception { - AgentProperties props = propsWithContent("power-source-type=csv\n"); - PowerSource source = PowerSourceFactory.getPowerSource(props); - assertNotNull(source); - assertInstanceOf(PowerJoularCsvSource.class, source); - } - - /** - * {@code power-source-type=ringbuffer} must produce a - * {@link PowerJoularRingBufferSource} instance. The path falls back to the - * OS-appropriate default; no file needs to exist for construction. - */ - @Test - void getPowerSource_ringbuffer_returnsRingBufferSource() throws Exception { - AgentProperties props = propsWithContent("power-source-type=ringbuffer\n"); - PowerSource source = PowerSourceFactory.getPowerSource(props); - assertNotNull(source); - assertInstanceOf(PowerJoularRingBufferSource.class, source); - } - - /** - * {@code power-source-type=rapl} must produce a {@link LinuxRaplPowerSource} - * instance. RAPL availability is checked during {@code initialize()}, so this - * construction test is platform-independent. - */ - @Test - void getPowerSource_rapl_returnsLinuxRaplSource() throws Exception { - AgentProperties props = propsWithContent("power-source-type=rapl\n"); - PowerSource source = PowerSourceFactory.getPowerSource(props); - assertNotNull(source); - assertInstanceOf(LinuxRaplPowerSource.class, source); - } - - /** - * The type string is normalised to lowercase before the switch statement, - * so {@code "CSV"} (all-caps) must produce the same result as {@code "csv"}. - */ - @Test - void getPowerSource_csvUppercase_returnsCSVSource() throws Exception { - AgentProperties props = propsWithContent("power-source-type=CSV\n"); - PowerSource source = PowerSourceFactory.getPowerSource(props); - assertNotNull(source); - assertInstanceOf(PowerJoularCsvSource.class, source); - } - - // ------------------------------------------------------------------------- - // Error paths - // ------------------------------------------------------------------------- - - /** - * An unrecognised type string must result in {@code null} being returned so - * the agent can detect the misconfiguration and abort startup cleanly, rather - * than attempting to use an undefined source. - */ - @Test - void getPowerSource_unknownType_returnsNull() throws Exception { - AgentProperties props = propsWithContent("power-source-type=grpc\n"); - assertNull(PowerSourceFactory.getPowerSource(props)); - } -} diff --git a/src/test/java/org/noureddine/joular/joularcodejava/agent/source/TestLogging.java b/src/test/java/org/noureddine/joular/joularcodejava/agent/source/TestLogging.java deleted file mode 100644 index 3fdd166..0000000 --- a/src/test/java/org/noureddine/joular/joularcodejava/agent/source/TestLogging.java +++ /dev/null @@ -1,38 +0,0 @@ -/* - * Copyright (c) 2026, Adel Noureddine. - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the - * GNU Lesser General Public License v3.0 (LGPL-3.0-only) - * which accompanies this distribution, and is available at - * https://www.gnu.org/licenses/lgpl-3.0.en.html - * - * Author : Adel Noureddine - */ - -package org.noureddine.joular.joularcodejava.agent.source; - -import org.junit.jupiter.api.function.Executable; - -import java.util.logging.Level; -import java.util.logging.Logger; - -final class TestLogging { - - private TestLogging() {} - - static void quietly(Class loggerOwner, Executable body) throws Exception { - Logger logger = Logger.getLogger(loggerOwner.getName()); - Level oldLevel = logger.getLevel(); - logger.setLevel(Level.OFF); - try { - body.execute(); - } catch (Exception | Error e) { - // Failed assertions and anything else worth seeing carry on untouched. - throw e; - } catch (Throwable t) { - throw new AssertionError(t); - } finally { - logger.setLevel(oldLevel); - } - } -} diff --git a/src/test/java/org/noureddine/joular/joularcodejava/agent/utils/AgentPropertiesTest.java b/src/test/java/org/noureddine/joular/joularcodejava/agent/utils/AgentPropertiesTest.java deleted file mode 100644 index 81b1e20..0000000 --- a/src/test/java/org/noureddine/joular/joularcodejava/agent/utils/AgentPropertiesTest.java +++ /dev/null @@ -1,284 +0,0 @@ -/* - * Copyright (c) 2026, Adel Noureddine. - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the - * GNU Lesser General Public License v3.0 (LGPL-3.0-only) - * which accompanies this distribution, and is available at - * https://www.gnu.org/licenses/lgpl-3.0.en.html - * - * Author : Adel Noureddine - */ - -package org.noureddine.joular.joularcodejava.agent.utils; - -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; - -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.List; - -import static org.junit.jupiter.api.Assertions.*; - -/** - * Unit tests for {@link AgentProperties}. - * - *

{@code AgentProperties} loads configuration in priority order: - *

    - *
  1. The classpath resource {@code joularcodejava.properties} (all values - * intentionally empty so the defaults are defined in code, not in the file).
  2. - *
  3. The file path pointed to by the system property - * {@code joularcodejava.properties} (if set).
  4. - *
  5. A {@code joularcodejava.properties} file in the working directory - * (only consulted if the system property is absent).
  6. - *
- * - *

Test isolation

- *

Every test sets the {@code joularcodejava.properties} system property before - * constructing {@link AgentProperties} and clears it in {@code @AfterEach} so that - * no test leaks configuration to its successors regardless of execution order. - * - *

Tests that want the built-in defaults point the system property at a path that - * does not exist on disk; {@code AgentProperties} then falls back to the classpath - * defaults without reading any external file. - */ -class AgentPropertiesTest { - - @TempDir - Path tempDir; - - @AfterEach - void cleanup() { - System.clearProperty("joularcodejava.properties"); - } - - /** - * Constructs an {@link AgentProperties} backed only by the classpath defaults. - * The system property is set to a non-existent path so neither the - * system-property path nor the working-directory fallback can accidentally - * load a local file during development. - */ - private AgentProperties defaults() { - System.setProperty("joularcodejava.properties", - tempDir.resolve("nonexistent.properties").toString()); - return new AgentProperties(); - } - - /** - * Writes {@code content} to a temp properties file, sets the system property - * to point at it, and returns a freshly constructed {@link AgentProperties}. - */ - private AgentProperties withContent(String content) throws Exception { - Path f = tempDir.resolve("joularcodejava.properties"); - Files.writeString(f, content, StandardCharsets.UTF_8); - System.setProperty("joularcodejava.properties", f.toString()); - return new AgentProperties(); - } - - // ------------------------------------------------------------------------- - // Default values (no external file) - // ------------------------------------------------------------------------- - - /** - * When no external file is loaded, the power source type must default to - * {@code "ringbuffer"} — the lowest-latency source, preferred over CSV or HTTP. - */ - @Test - void defaults_powerSourceType_ringbuffer() { - assertEquals("ringbuffer", defaults().getPowerSourceType()); - } - - /** - * The default CSV file path is {@code "powerjoular-data.csv"}, resolved - * relative to the agent's working directory. - */ - @Test - void defaults_csvPath_default() { - assertEquals("powerjoular-data.csv", defaults().getPowerJoularCsvPath()); - } - - /** - * The default stack-sampling interval is 10 ms, a balance between - * attribution accuracy and monitoring overhead. - */ - @Test - void defaults_sampleRate_ten() { - assertEquals(10L, defaults().getSampleRateMs()); - } - - /** - * The default results directory is {@code "joular-code-java-results"}, - * resolved relative to the agent's working directory. - */ - @Test - void defaults_resultsPath_default() { - assertEquals("joular-code-java-results", defaults().getResultsPath()); - } - - /** - * When no filtering prefix is configured, the returned list must be empty, - * which means all observed branches appear in both output files. - */ - @Test - void defaults_filteringPrefixes_empty() { - assertTrue(defaults().getMethodsFilteringPrefixes().isEmpty()); - } - - // ------------------------------------------------------------------------- - // Override from external file - // ------------------------------------------------------------------------- - - /** - * A value supplied in the external file overrides the classpath default. - * Setting {@code power-source-type=csv} must be reflected by the getter. - */ - @Test - void fromFile_powerSourceType_csv() throws Exception { - assertEquals("csv", withContent("power-source-type=csv\n").getPowerSourceType()); - } - - /** - * A property set to whitespace only is treated the same as absent: after - * trimming, the value is empty, so the code falls back to the default. - */ - @Test - void fromFile_emptyValue_usesDefault() throws Exception { - // Three spaces → trim → "" → use default "ringbuffer" - assertEquals("ringbuffer", withContent("power-source-type= \n").getPowerSourceType()); - } - - /** - * A valid positive integer for {@code stack-monitoring-sample-rate} must - * be returned as a {@code long} without modification. - */ - @Test - void fromFile_sampleRate_50() throws Exception { - assertEquals(50L, withContent("stack-monitoring-sample-rate=50\n").getSampleRateMs()); - } - - /** - * Zero is not a valid sample rate (the constraint is {@code > 0}), so - * the property must be rejected and the default 10 ms returned. - */ - @Test - void fromFile_zeroSampleRate_usesDefault() throws Exception { - assertEquals(10L, withContent("stack-monitoring-sample-rate=0\n").getSampleRateMs()); - } - - /** - * A negative value is also not a valid sample rate; the default must - * be returned and a warning logged. - */ - @Test - void fromFile_negativeSampleRate_usesDefault() throws Exception { - assertEquals(10L, withContent("stack-monitoring-sample-rate=-5\n").getSampleRateMs()); - } - - /** - * A non-numeric value causes a {@code NumberFormatException} inside - * {@code getPositiveLongProperty}, which logs a warning and returns the - * default rather than propagating the exception. - */ - @Test - void fromFile_nonNumericSampleRate_usesDefault() throws Exception { - assertEquals(10L, withContent("stack-monitoring-sample-rate=fast\n").getSampleRateMs()); - } - - /** - * The CSV file path accepts any string value without validation, because - * the file is opened lazily during the monitoring loop rather than at - * configuration time. - */ - @Test - void fromFile_csvPath_customValue() throws Exception { - assertEquals("/data/power.csv", - withContent("powerjoular-csv-path=/data/power.csv\n").getPowerJoularCsvPath()); - } - - /** - * The results directory path accepts any string value and is returned - * without modification. - */ - @Test - void fromFile_resultsPath_customValue() throws Exception { - assertEquals("/tmp/results", - withContent("results-path=/tmp/results\n").getResultsPath()); - } - - /** - * A single prefix must be parsed into a one-element list with the value - * trimmed. - */ - @Test - void fromFile_filteringPrefixes_single() throws Exception { - List prefixes = withContent("methods-filtering-prefix=com.example\n") - .getMethodsFilteringPrefixes(); - assertEquals(List.of("com.example"), prefixes); - } - - /** - * Multiple comma-separated prefixes must each be individually trimmed of - * surrounding whitespace and returned as separate list entries. - */ - @Test - void fromFile_filteringPrefixes_multiple_trimmed() throws Exception { - List prefixes = withContent( - "methods-filtering-prefix=com.example, org.myapp , io.foo\n") - .getMethodsFilteringPrefixes(); - assertEquals(List.of("com.example", "org.myapp", "io.foo"), prefixes); - } - - /** - * Empty tokens produced by consecutive commas (e.g., {@code "com.a,,com.b"}) - * must be silently discarded; only non-blank entries appear in the result. - */ - @Test - void fromFile_filteringPrefixes_emptyTokensSkipped() throws Exception { - List prefixes = withContent( - "methods-filtering-prefix=com.a,,com.b\n") - .getMethodsFilteringPrefixes(); - assertEquals(List.of("com.a", "com.b"), prefixes); - } - - // ------------------------------------------------------------------------- - // OS-dependent ring-buffer path - // ------------------------------------------------------------------------- - - /** - * The default ring buffer path is determined at runtime by inspecting - * {@code os.name}: - *

    - *
  • Windows → {@code "%PROGRAMDATA%\joularcorering"} (a real file, readable by any user)
  • - *
  • macOS → {@code "/tmp/joularcorering"}
  • - *
  • Linux → {@code "/dev/shm/joularcorering"} (shared-memory filesystem)
  • - *
- * The test branches on the current platform so it remains correct in all - * three CI environments. - */ - @Test - void getPowerJoularRingBufferPath_default_isOsDependent() { - String os = System.getProperty("os.name").toLowerCase(); - String path = defaults().getPowerJoularRingBufferPath(); - assertNotNull(path); - assertFalse(path.isEmpty()); - if (os.contains("win")) { - assertTrue(path.endsWith("\\joularcorering"), "Windows path: " + path); - } else if (os.contains("mac")) { - assertEquals("/tmp/joularcorering", path); - } else { - assertEquals("/dev/shm/joularcorering", path); - } - } - - /** - * An explicitly configured ring buffer path must override the OS-dependent - * default, allowing the agent to point at a non-standard location. - */ - @Test - void getPowerJoularRingBufferPath_customValue_returnsCustom() throws Exception { - assertEquals("/custom/ring", - withContent("powerjoular-ringbuffer-path=/custom/ring\n").getPowerJoularRingBufferPath()); - } -}