diff --git a/Dockerfile b/Dockerfile index f4d748e..3d9852f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,6 +7,7 @@ WORKDIR /app # Copy Maven wrapper and pom.xml first for better layer caching COPY mvnw . COPY mvnw.cmd . +COPY spotless ./spotless COPY .mvn .mvn COPY pom.xml . diff --git a/Dockerfile.scale b/Dockerfile.scale index dbaff61..6eb1664 100644 --- a/Dockerfile.scale +++ b/Dockerfile.scale @@ -9,6 +9,7 @@ COPY mvnw . COPY mvnw.cmd . COPY .mvn .mvn COPY pom.xml . +COPY spotless ./spotless RUN ./mvnw dependency:go-offline -B diff --git a/Dockerfile.scale.standard b/Dockerfile.scale.standard index 8ca0647..06bd66e 100644 --- a/Dockerfile.scale.standard +++ b/Dockerfile.scale.standard @@ -9,6 +9,7 @@ COPY mvnw . COPY mvnw.cmd . COPY .mvn .mvn COPY pom.xml . +COPY spotless ./spotless RUN ./mvnw dependency:go-offline -B diff --git a/Dockerfile.standard b/Dockerfile.standard index 0754372..1e2d9b3 100644 --- a/Dockerfile.standard +++ b/Dockerfile.standard @@ -9,6 +9,7 @@ COPY mvnw . COPY mvnw.cmd . COPY .mvn .mvn COPY pom.xml . +COPY spotless ./spotless RUN ./mvnw dependency:go-offline -B diff --git a/README.md b/README.md index fa3a37f..0d0b268 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # TradeStreamEE: High-Frequency Trading Reference Architecture -TradeStreamEE demonstrates how Jakarta EE applications can achieve low-latency, high-throughput performance by combining modern messaging (Aeron), efficient serialization (SBE), and a concurrent garbage collector like Azul C4. +TradeStreamEE shows how Jakarta EE applications can achieve low-latency, high-throughput performance by combining modern messaging (Aeron), efficient serialization (SBE), and a concurrent garbage collector like Azul C4. -The application simulates a high-frequency trading dashboard that ingests tens of thousands of market data messages per second, processes them in real-time, and broadcasts updates to a web frontend without the latency spikes associated with traditional Java garbage collection. +The application simulates a high-frequency trading dashboard that ingests 30,000-100,000 market data messages per second, processes them in real-time, and broadcasts updates to a web frontend without the latency spikes associated with traditional Java garbage collection. ## Core Technologies @@ -16,13 +16,13 @@ Ultra-low latency messaging that bypasses the network stack when components run Simple Binary Encoding is the financial industry standard for high-frequency trading. Encodes data as compact binary rather than human-readable text, eliminating parsing overhead and reducing memory allocation. -### Payara Micro (Jakarta EE Runtime) +### Payara Micro 7.2025.2 (Jakarta EE Runtime) A cloud-native Jakarta EE 11 application server that provides Jakarta CDI for dependency injection, Jakarta WebSocket for real-time browser communication, and Jakarta REST for REST endpoints. Lightweight (~100MB) and fast-starting. -### Azul Platform Prime (Runtime) +### Azul Platform Prime 21 (Runtime) -A JVM with the C4 garbage collector that performs cleanup concurrently with application execution, avoiding the stop-the-world pauses that cause latency spikes in traditional JVMs. +A JVM with the C4 (Continuously Concurrent Compacting Collector) garbage collector. C4 performs cleanup concurrently with application execution, avoiding the stop-the-world pauses that cause latency spikes in traditional JVMs. Azul markets C4 as a "pauseless" collector due to its sub-millisecond pause times across all heap sizes. Integration: Payara Micro manages the Aeron publisher/subscriber lifecycle via CDI. The publisher encodes market data into binary using SBE, sends it through Aeron's shared memory transport, and the subscriber decodes it without allocating Java objects. This minimal garbage generation allows C4's concurrent collection to handle cleanup while maintaining flat latency at high throughput. @@ -33,7 +33,8 @@ Enterprise Java applications must often balance two competing requirements: 1. High Throughput: Ingesting massive data streams (IoT, Financial Data). 2. Low Latency: Processing data without stop-the-world pauses. -Standard JVMs (using G1GC or ParallelGC) can exhibit latency spikes under high load, potentially causing UI freezes or missed SLAs. TradeStreamEE demonstrates that by combining a modern, broker-less transport (Aeron) with a pauseless runtime (Azul C4), standard Jakarta EE applications can achieve microsecond-level latency and high throughput. +Standard JVMs (using G1GC or ParallelGC) exhibit latency spikes under high load, potentially causing UI freezes or missed SLAs. +TradeStreamEE shows that through combining a modern, broker-less transport (Aeron) with a concurrent runtime (Azul C4), standard Jakarta EE applications can achieve microsecond-level latency and high throughput. ### Comparison Methodology @@ -49,7 +50,9 @@ This isolation ensures that observed performance differences are attributable so ### Primary Use Case: Side-by-Side JVM Comparison -TradeStreamEE enables side-by-side JVM comparison by deploying both clusters simultaneously under identical workloads. Both clusters execute the same application binary and configuration, isolating the garbage collector as the sole variable. This approach allows for real-time observation of performance differences in a shared environment, providing a more objective comparison than traditional serial benchmarking. +TradeStreamEE enables side-by-side JVM comparison by deploying both clusters simultaneously under identical workloads. +Both clusters execute the same application binary and configuration, isolating the garbage collector as the sole variable. +This approach allows for real-time observation of performance differences in a shared environment. ```bash ./start-comparison.sh all @@ -62,7 +65,7 @@ Once deployed, access the applications: - C4 Cluster: http://localhost:8080/trader-stream-ee/ - G1 Cluster: http://localhost:9080/trader-stream-ee/ -The "GC Pause Time (Live)" chart in Grafana will demonstrate the performance characteristics: C4 typically maintains a consistent latency profile, while G1GC may exhibit spikes and sawtooth patterns under load. +The "GC Pause Time (Live)" chart in Grafana shows the performance characteristics: C4 maintains consistent latency (≤1ms pauses), while G1GC exhibits spikes and sawtooth patterns under load. Other options: @@ -75,12 +78,12 @@ Other options: Use `./start.sh` for testing individual configurations or architectural modes. This runs ONE JVM at a time for focused testing. -| Scenario | Command | JVM | Architecture | Purpose | -|:-------------------------|:-----------------------------|:--------------------|:------------------|:-----------------------------------------------------------------| -| Modern Stack | `./start.sh azul-aeron` | Azul Prime (C4) | Aeron (Optimized) | Peak performance with pauseless GC + zero-copy transport | -| Legacy Baseline | `./start.sh standard-direct` | Standard JDK (G1GC) | Direct (Heavy) | Baseline: high allocation on G1GC, expect jitter | -| Fixing Legacy Code | `./start.sh azul-direct` | Azul Prime (C4) | Direct (Heavy) | Show how C4 stabilizes high-allocation apps without code changes | -| Optimizing Standard Java | `./start.sh standard-aeron` | Standard JDK (G1GC) | Aeron (Optimized) | Test if architectural optimization helps G1GC performance | +| Scenario | Command | JVM | Architecture | Purpose | +|:-------------------------|:-----------------------------|:-----------------------|:------------------|:-----------------------------------------------------------------| +| Modern Stack | `./start.sh azul-aeron` | Azul Prime (C4) | Aeron (Optimized) | Peak performance with concurrent GC + zero-copy transport | +| Legacy Baseline | `./start.sh standard-direct` | Eclipse Temurin (G1GC) | Direct (Heavy) | Baseline: high allocation on G1GC, expect jitter | +| Fixing Legacy Code | `./start.sh azul-direct` | Azul Prime (C4) | Direct (Heavy) | Show how C4 stabilizes high-allocation apps without code changes | +| Optimizing Standard Java | `./start.sh standard-aeron` | Eclipse Temurin (G1GC) | Aeron (Optimized) | Test if architectural optimization helps G1GC performance | Utilities: @@ -101,21 +104,21 @@ The application implements a Hybrid Architecture: 3. Application Layer (Jakarta EE 11): * Payara Micro 7 serves as the container. * CDI manages the lifecycle of the Aeron Publisher and Subscriber. - * Jakarta Concurrency 3.1 leverages Virtual Threads for high-throughput memory pressure simulation. + * Jakarta Concurrency 3.1 uses Virtual Threads for high-throughput memory pressure simulation. * WebSockets push updates to the browser. 4. Runtime Layer: - * Azul Platform Prime uses the C4 Collector to clean up the "garbage" created by the WebSocket layer concurrently, ensuring a flat latency profile. + * Azul Platform Prime uses the C4 Collector to clean up garbage created by the WebSocket layer concurrently, ensuring a flat latency profile. ## Tech Stack -| Component | Technology | Role | -|:-----------|:------------------------------------|:----------------------------------------| -| Runtime | Azul Platform Prime (Zulu Prime 21) | The Pauseless JVM engine. | -| App Server | Payara Micro 7 (Jakarta EE 11) | Cloud-native Jakarta EE runtime. | -| Transport | Aeron | Low-latency, high-throughput messaging. | -| Encoding | SBE (Simple Binary Encoding) | Binary serialization (FIX standard). | -| Frontend | HTML5 / Chart.js | Real-time visualization via WebSockets. | -| Build | Docker / Maven | Containerized deployment. | +| Component | Technology | Role | +|:-----------|:--------------------------------------|:------------------------------------------| +| Runtime | Azul Platform Prime 21 | Concurrent JVM with C4 garbage collector. | +| App Server | Payara Micro 7.2025.2 (Jakarta EE 11) | Cloud-native Jakarta EE runtime. | +| Transport | Aeron | Low-latency, high-throughput messaging. | +| Encoding | SBE (Simple Binary Encoding) | Binary serialization (FIX standard). | +| Frontend | HTML5 / Chart.js | Real-time visualization via WebSockets. | +| Build | Docker / Maven | Containerized deployment. | ## Ingestion Architectures @@ -161,7 +164,7 @@ Implementation: Performance Characteristics: - High Allocation: Gigabytes of temporary String objects per second -- GC Pressure: Frequent pauses on G1GC; C4's concurrent collection avoids stop-the-world pauses but still performs work +- GC Pressure: Frequent pauses on G1GC; C4's concurrent collection maintains sub-millisecond pauses ### AERON Mode (Optimized Path, Default) @@ -180,9 +183,9 @@ Implementation: Performance Characteristics: -- Low Allocation: Almost no garbage in the ingestion hot-path +- Low Allocation: Minimal garbage in the ingestion hot-path - High Throughput: Aeron IPC handles millions of messages/sec with sub-microsecond latency -- Both JVMs benefit from reduced allocation, but C4 maintains flat latency profile +- Both JVMs benefit from reduced allocation; C4 maintains consistent latency profile ## Configuration & Tuning @@ -221,11 +224,11 @@ Infrastructure improvements: - GC Logging: Detailed event logging with decorators for analysis - Rate-Limited Logging: Prevents log flooding during high-throughput operations -Note: Azul Platform Prime uses C4 by default; we don't specify `-XX:+UseZGC` since C4 is the native, optimized collector for Azul Prime. +Note: Azul Platform Prime uses C4 by default; we don't specify `-XX:+UseG1GC` since C4 is the native, optimized collector for Azul Prime. ### GC Monitoring & Stress Testing -The application includes comprehensive GC monitoring and memory pressure testing capabilities to demonstrate JVM performance differences: +The application includes comprehensive GC monitoring and memory pressure testing capabilities to show JVM performance differences: #### GC Statistics Collection @@ -261,9 +264,9 @@ Test Scenarios: Expected Behavior: * G1: Stop-the-world pauses that scale with live set size, remembered set scanning overhead, compaction pauses under fragmentation -* C4: Zero pauses across all scenarios due to concurrent collection, no remembered sets, concurrent compaction +* C4: Pauses ≤1ms across all scenarios due to concurrent collection, no remembered sets, concurrent compaction -Each scenario uses 4 parallel virtual threads to generate allocation load. This multi-threaded pattern demonstrates G1's stop-the-world impact when all application threads must pause simultaneously. +Each scenario uses 4 parallel virtual threads to generate allocation load. This multi-threaded pattern shows G1's stop-the-world impact when all application threads must pause simultaneously. #### GC Scenario Control @@ -274,7 +277,7 @@ The web UI includes scenario selection controls that allow: * Side-by-side pause time visualization with phase breakdown * Observation of collector behavior under specific pathological conditions -This feature enables targeted demonstration of G1's architectural limitations versus C4's concurrent collection model. +This feature enables targeted observation of G1's architectural limitations versus C4's concurrent collection model. ## Monitoring & Observability @@ -387,8 +390,8 @@ Working Tests (170/170 passing): Coverage Metrics: - Tests: 170 unit tests with 100% pass rate -- Monitoring Coverage: >90% (SLAMonitor, GCStatsService) -- REST API Coverage: >85% (Resources and DTOs) +- Monitoring Coverage: ≥90% (SLAMonitor, GCStatsService) +- REST API Coverage: ≥85% (Resources and DTOs) - Instruction Coverage: High coverage for business logic; integration logic relies on `test.sh`. ### Test Categories @@ -418,6 +421,7 @@ For readers unfamiliar with high-frequency trading concepts, this glossary expla | Price Ladder | The hierarchical structure of bid and ask prices in an order book. Bid prices decrease as you move away from the best bid; ask prices increase as you move away from the best ask. | | Crossed Market | An abnormal market condition where the best bid price is higher than the best ask price. This should never occur in normal trading and indicates a data error or system malfunction. | | Flash Crash | A sudden, severe market downturn followed by rapid recovery, often triggered by algorithmic trading systems. The 2010 Flash Crash saw the Dow Jones drop nearly 1,000 points in minutes. These events create extreme allocation bursts as systems process massive order flow. | +| SLA (Service Level Agreement) | Performance guarantees, such as "99.9% of trades execute within 5ms". GC pauses can cause SLA violations if they exceed latency budgets. | ### Memory & Performance @@ -469,8 +473,15 @@ For readers unfamiliar with high-frequency trading concepts, this glossary expla | Ingestion Pipeline / Ingestion Layer | The component responsible for receiving and processing incoming market data feeds. Must handle extreme throughput (millions of messages/sec) with minimal latency. | | Hot Path / Critical Path | The code execution path that runs most frequently and must be highly optimized. In HFT, the ingestion and order routing paths are hot paths where even nanoseconds matter. | | Market Event | Significant market occurrences that trigger increased trading activity: market open/close, economic announcements, earnings releases, or sudden price movements. | -| SLA (Service Level Agreement) | Performance guarantees, such as "99.9% of trades execute within 5ms". GC pauses can cause SLA violations if they exceed latency budgets. | + +## References + +- [Azul C4 Garbage Collection Documentation](https://docs.azul.com/prime/c4-garbage-collection.html) +- [Azul Platform Prime Product Page](https://www.azul.com/products/components/pgc/) +- [Explanation of Azul's "Pauseless" Garbage Collector](https://stackoverflow.com/questions/4491260/explanation-of-azuls-pauseless-garbage-collector) +- [What To Expect When Evaluating Azul Platform Prime](https://docs.azul.com/prime/what-to-expect) +- [Apache Kafka Performance on Azul Platform Prime vs Vanilla OpenJDK](https://www.azul.com/blog/apache-kafka-performance-on-azul-platform-prime-vs-vanilla-openjdk/) ## License -This project is a reference implementation provided for demonstration purposes. +This project is a reference implementation showing low-latency Java techniques for educational purposes. diff --git a/pom.xml b/pom.xml index ab96153..9f82248 100644 --- a/pom.xml +++ b/pom.xml @@ -324,10 +324,9 @@ 3.0.0 - - 1.17.0 - - + + ${project.basedir}/spotless/java-formatter.xml + diff --git a/src/main/java/fish/payara/resource/HelloWorldResource.java b/src/main/java/fish/payara/resource/HelloWorldResource.java index 83991fa..0a7c26b 100644 --- a/src/main/java/fish/payara/resource/HelloWorldResource.java +++ b/src/main/java/fish/payara/resource/HelloWorldResource.java @@ -19,37 +19,28 @@ @Path("hello") public class HelloWorldResource { - @Inject - @ConfigProperty(name = "defaultName", defaultValue = "world") - private String defaultName; + @Inject + @ConfigProperty(name = "defaultName", defaultValue = "world") + private String defaultName; - @GET - @Operation(summary = "Get a personalized greeting") - @APIResponses( - value = { - @APIResponse(responseCode = "200", description = "Successful operation"), - @APIResponse(responseCode = "400", description = "Invalid input") - }) - @Counted(name = "helloEndpointCount", description = "Count of calls to the hello endpoint") - @Timed(name = "helloEndpointTime", description = "Time taken to execute the hello endpoint") - @Timeout(3000) - @Retry(maxRetries = 3) - @Fallback(fallbackMethod = "fallbackMethod") - public Response hello( - @QueryParam("name") - @Parameter( - name = "name", - description = "Name to include in the greeting", - required = false, - example = "John") - String name) { - if ((name == null) || name.trim().isEmpty()) { - name = defaultName; + @GET + @Operation(summary = "Get a personalized greeting") + @APIResponses(value = {@APIResponse(responseCode = "200", description = "Successful operation"), + @APIResponse(responseCode = "400", description = "Invalid input")}) + @Counted(name = "helloEndpointCount", description = "Count of calls to the hello endpoint") + @Timed(name = "helloEndpointTime", description = "Time taken to execute the hello endpoint") + @Timeout(3000) + @Retry(maxRetries = 3) + @Fallback(fallbackMethod = "fallbackMethod") + public Response hello( + @QueryParam("name") @Parameter(name = "name", description = "Name to include in the greeting", required = false, example = "John") String name) { + if ((name == null) || name.trim().isEmpty()) { + name = defaultName; + } + return Response.ok(name).build(); } - return Response.ok(name).build(); - } - public Response fallbackMethod(@QueryParam("name") String name) { - return Response.ok("Fallback data").build(); - } + public Response fallbackMethod(@QueryParam("name") String name) { + return Response.ok("Fallback data").build(); + } } diff --git a/src/main/java/fish/payara/trader/aeron/AeronSubscriberBean.java b/src/main/java/fish/payara/trader/aeron/AeronSubscriberBean.java index a5335c0..3c0a36d 100644 --- a/src/main/java/fish/payara/trader/aeron/AeronSubscriberBean.java +++ b/src/main/java/fish/payara/trader/aeron/AeronSubscriberBean.java @@ -21,152 +21,137 @@ import org.eclipse.microprofile.config.inject.ConfigProperty; /** - * Aeron Ingress Singleton Bean Launches an embedded MediaDriver and subscribes to market data - * stream. Uses SBE decoders for zero-copy message processing. Runs in a dedicated thread to - * continuously poll for messages. IMPORTANT: This must initialize BEFORE MarketDataPublisher + * Aeron Ingress Singleton Bean Launches an embedded MediaDriver and subscribes to market data stream. Uses SBE decoders for zero-copy message processing. Runs + * in a dedicated thread to continuously poll for messages. IMPORTANT: This must initialize BEFORE MarketDataPublisher */ @ApplicationScoped public class AeronSubscriberBean { - private static final Logger LOGGER = Logger.getLogger(AeronSubscriberBean.class.getName()); + private static final Logger LOGGER = Logger.getLogger(AeronSubscriberBean.class.getName()); - private static final String CHANNEL = "aeron:ipc"; - private static final int STREAM_ID = 1001; - private static final int FRAGMENT_LIMIT = 10; + private static final String CHANNEL = "aeron:ipc"; + private static final int STREAM_ID = 1001; + private static final int FRAGMENT_LIMIT = 10; - private MediaDriver mediaDriver; - private Aeron aeron; - private Subscription subscription; - private volatile boolean running = false; - private Future pollingFuture; + private MediaDriver mediaDriver; + private Aeron aeron; + private Subscription subscription; + private volatile boolean running = false; + private Future pollingFuture; - @Inject private MarketDataFragmentHandler fragmentHandler; + @Inject + private MarketDataFragmentHandler fragmentHandler; - @Inject @VirtualThreadExecutor private ManagedExecutorService managedExecutorService; + @Inject + @VirtualThreadExecutor + private ManagedExecutorService managedExecutorService; - @Inject - @ConfigProperty(name = "TRADER_INGESTION_MODE", defaultValue = "AERON") - private String ingestionMode; + @Inject + @ConfigProperty(name = "TRADER_INGESTION_MODE", defaultValue = "AERON") + private String ingestionMode; - void contextInitialized(@Observes @Initialized(ApplicationScoped.class) Object event) { - managedExecutorService.submit(this::init); - } - - public void init() { - if ("DIRECT".equalsIgnoreCase(ingestionMode)) { - LOGGER.info("Running in DIRECT mode - Skipping Aeron/MediaDriver initialization."); - return; + void contextInitialized(@Observes @Initialized(ApplicationScoped.class) Object event) { + managedExecutorService.submit(this::init); } - LOGGER.info("Initializing Aeron Subscriber Bean..."); - - try { - LOGGER.info("Launching embedded MediaDriver..."); - mediaDriver = - MediaDriver.launchEmbedded( - new MediaDriver.Context() - .threadingMode(ThreadingMode.SHARED) - .dirDeleteOnStart(true) - .dirDeleteOnShutdown(true)); - - LOGGER.info("MediaDriver launched at: " + mediaDriver.aeronDirectoryName()); - LOGGER.info("Connecting Aeron client..."); - aeron = - Aeron.connect( - new Aeron.Context() - .aeronDirectoryName(mediaDriver.aeronDirectoryName()) - .errorHandler(this::onError) - .availableImageHandler( - image -> LOGGER.info("Available image: " + image.sourceIdentity())) - .unavailableImageHandler( - image -> LOGGER.info("Unavailable image: " + image.sourceIdentity()))); - LOGGER.info("Adding subscription on channel: " + CHANNEL + ", stream: " + STREAM_ID); - subscription = aeron.addSubscription(CHANNEL, STREAM_ID); - startPolling(); - LOGGER.info("Aeron Subscriber Bean initialized successfully"); - - } catch (Exception e) { - LOGGER.log(Level.SEVERE, "Failed to initialize Aeron Subscriber Bean", e); - cleanup(); - throw new RuntimeException("Failed to initialize Aeron", e); + public void init() { + if ("DIRECT".equalsIgnoreCase(ingestionMode)) { + LOGGER.info("Running in DIRECT mode - Skipping Aeron/MediaDriver initialization."); + return; + } + + LOGGER.info("Initializing Aeron Subscriber Bean..."); + + try { + LOGGER.info("Launching embedded MediaDriver..."); + mediaDriver = MediaDriver + .launchEmbedded(new MediaDriver.Context().threadingMode(ThreadingMode.SHARED).dirDeleteOnStart(true).dirDeleteOnShutdown(true)); + + LOGGER.info("MediaDriver launched at: " + mediaDriver.aeronDirectoryName()); + LOGGER.info("Connecting Aeron client..."); + aeron = Aeron.connect(new Aeron.Context().aeronDirectoryName(mediaDriver.aeronDirectoryName()) + .errorHandler(this::onError) + .availableImageHandler(image -> LOGGER.info("Available image: " + image.sourceIdentity())) + .unavailableImageHandler(image -> LOGGER.info("Unavailable image: " + image.sourceIdentity()))); + LOGGER.info("Adding subscription on channel: " + CHANNEL + ", stream: " + STREAM_ID); + subscription = aeron.addSubscription(CHANNEL, STREAM_ID); + startPolling(); + LOGGER.info("Aeron Subscriber Bean initialized successfully"); + + } catch (Exception e) { + LOGGER.log(Level.SEVERE, "Failed to initialize Aeron Subscriber Bean", e); + cleanup(); + throw new RuntimeException("Failed to initialize Aeron", e); + } } - } - - /** Start background task to continuously poll for messages */ - private void startPolling() { - running = true; - pollingFuture = - managedExecutorService.submit( - () -> { - LOGGER.info("Aeron polling task started"); - - final IdleStrategy idleStrategy = - new BackoffIdleStrategy( - 100, - 10, - TimeUnit.MICROSECONDS.toNanos(1), - TimeUnit.MICROSECONDS.toNanos(100)); - - while (running && !Thread.currentThread().isInterrupted()) { + + /** Start background task to continuously poll for messages */ + private void startPolling() { + running = true; + pollingFuture = managedExecutorService.submit(() -> { + LOGGER.info("Aeron polling task started"); + + final IdleStrategy idleStrategy = new BackoffIdleStrategy(100, 10, TimeUnit.MICROSECONDS.toNanos(1), TimeUnit.MICROSECONDS.toNanos(100)); + + while (running && !Thread.currentThread().isInterrupted()) { try { - final int fragmentsRead = subscription.poll(fragmentHandler, FRAGMENT_LIMIT); + final int fragmentsRead = subscription.poll(fragmentHandler, FRAGMENT_LIMIT); - idleStrategy.idle(fragmentsRead); + idleStrategy.idle(fragmentsRead); } catch (Exception e) { - LOGGER.log(Level.SEVERE, "Error polling subscription", e); + LOGGER.log(Level.SEVERE, "Error polling subscription", e); } - } + } + + LOGGER.info("Aeron polling task stopped"); + }); + } - LOGGER.info("Aeron polling task stopped"); - }); - } + /** Error handler for Aeron */ + private void onError(Throwable throwable) { + LOGGER.log(Level.SEVERE, "Aeron error occurred", throwable); + } - /** Error handler for Aeron */ - private void onError(Throwable throwable) { - LOGGER.log(Level.SEVERE, "Aeron error occurred", throwable); - } + @PreDestroy + public void shutdown() { + LOGGER.info("Shutting down Aeron Subscriber Bean..."); + running = false; - @PreDestroy - public void shutdown() { - LOGGER.info("Shutting down Aeron Subscriber Bean..."); - running = false; + if (pollingFuture != null) { + pollingFuture.cancel(true); + } - if (pollingFuture != null) { - pollingFuture.cancel(true); + cleanup(); + LOGGER.info("Aeron Subscriber Bean shut down"); } - cleanup(); - LOGGER.info("Aeron Subscriber Bean shut down"); - } - - /** Clean up all Aeron resources */ - private void cleanup() { - CloseHelper.quietClose(subscription); - CloseHelper.quietClose(aeron); - CloseHelper.quietClose(mediaDriver); - } - - /** Get subscription statistics */ - public String getStatus() { - if (subscription != null) { - return String.format( - "Channel: %s, Stream: %d, Images: %d, Running: %b", - subscription.channel(), subscription.streamId(), subscription.imageCount(), running); + /** Clean up all Aeron resources */ + private void cleanup() { + CloseHelper.quietClose(subscription); + CloseHelper.quietClose(aeron); + CloseHelper.quietClose(mediaDriver); } - return "Not initialized"; - } - /** Get the Aeron directory name for connecting publishers */ - public String getAeronDirectoryName() { - if (mediaDriver != null) { - return mediaDriver.aeronDirectoryName(); + /** Get subscription statistics */ + public String getStatus() { + if (subscription != null) { + return String.format("Channel: %s, Stream: %d, Images: %d, Running: %b", subscription.channel(), subscription.streamId(), subscription.imageCount(), + running); + } + return "Not initialized"; } - return null; - } - /** Check if the MediaDriver is ready */ - public boolean isReady() { - return mediaDriver != null && aeron != null && subscription != null && running; - } + /** Get the Aeron directory name for connecting publishers */ + public String getAeronDirectoryName() { + if (mediaDriver != null) { + return mediaDriver.aeronDirectoryName(); + } + return null; + } + + /** Check if the MediaDriver is ready */ + public boolean isReady() { + return mediaDriver != null && aeron != null && subscription != null && running; + } } diff --git a/src/main/java/fish/payara/trader/aeron/MarketDataFragmentHandler.java b/src/main/java/fish/payara/trader/aeron/MarketDataFragmentHandler.java index d0ea6a6..8ec3125 100644 --- a/src/main/java/fish/payara/trader/aeron/MarketDataFragmentHandler.java +++ b/src/main/java/fish/payara/trader/aeron/MarketDataFragmentHandler.java @@ -11,300 +11,280 @@ import org.agrona.DirectBuffer; /** - * FragmentHandler that uses SBE Flyweights for zero-copy message decoding. This handler processes - * Aeron fragments by: 1. Decoding the SBE message header to identify message type 2. Using the - * appropriate SBE decoder (flyweight pattern) 3. Extracting data without object allocation 4. - * Broadcasting as JSON (intentionally creating garbage for GC stress testing) + * FragmentHandler that uses SBE Flyweights for zero-copy message decoding. This handler processes Aeron fragments by: 1. Decoding the SBE message header to + * identify message type 2. Using the appropriate SBE decoder (flyweight pattern) 3. Extracting data without object allocation 4. Broadcasting as JSON + * (intentionally creating garbage for GC stress testing) */ @ApplicationScoped public class MarketDataFragmentHandler implements FragmentHandler { - private static final Logger LOGGER = Logger.getLogger(MarketDataFragmentHandler.class.getName()); + private static final Logger LOGGER = Logger.getLogger(MarketDataFragmentHandler.class.getName()); - private final MessageHeaderDecoder headerDecoder = new MessageHeaderDecoder(); + private final MessageHeaderDecoder headerDecoder = new MessageHeaderDecoder(); - private final TradeDecoder tradeDecoder = new TradeDecoder(); - private final QuoteDecoder quoteDecoder = new QuoteDecoder(); - private final MarketDepthDecoder marketDepthDecoder = new MarketDepthDecoder(); - private final OrderAckDecoder orderAckDecoder = new OrderAckDecoder(); - private final HeartbeatDecoder heartbeatDecoder = new HeartbeatDecoder(); + private final TradeDecoder tradeDecoder = new TradeDecoder(); + private final QuoteDecoder quoteDecoder = new QuoteDecoder(); + private final MarketDepthDecoder marketDepthDecoder = new MarketDepthDecoder(); + private final OrderAckDecoder orderAckDecoder = new OrderAckDecoder(); + private final HeartbeatDecoder heartbeatDecoder = new HeartbeatDecoder(); - private long messagesProcessed = 0; - private long messagesBroadcast = 0; - private long lastLogTime = System.currentTimeMillis(); + private long messagesProcessed = 0; + private long messagesBroadcast = 0; + private long lastLogTime = System.currentTimeMillis(); - private static final int SAMPLE_RATE = 50; - private long sampleCounter = 0; + private static final int SAMPLE_RATE = 50; + private long sampleCounter = 0; - @Inject MarketDataBroadcaster broadcaster; + @Inject + MarketDataBroadcaster broadcaster; - private final byte[] symbolBuffer = new byte[128]; - private final StringBuilder sb = new StringBuilder(1024); + private final byte[] symbolBuffer = new byte[128]; + private final StringBuilder sb = new StringBuilder(1024); - @Override - public void onFragment(DirectBuffer buffer, int offset, int length, Header header) { - try { - headerDecoder.wrap(buffer, offset); + @Override + public void onFragment(DirectBuffer buffer, int offset, int length, Header header) { + try { + headerDecoder.wrap(buffer, offset); - final int templateId = headerDecoder.templateId(); - final int actingBlockLength = headerDecoder.blockLength(); - final int actingVersion = headerDecoder.version(); + final int templateId = headerDecoder.templateId(); + final int actingBlockLength = headerDecoder.blockLength(); + final int actingVersion = headerDecoder.version(); - offset += headerDecoder.encodedLength(); + offset += headerDecoder.encodedLength(); - sampleCounter++; - final boolean shouldBroadcast = (sampleCounter % SAMPLE_RATE == 0); + sampleCounter++; + final boolean shouldBroadcast = (sampleCounter % SAMPLE_RATE == 0); - switch (templateId) { - case TradeDecoder.TEMPLATE_ID: - processTrade(buffer, offset, actingBlockLength, actingVersion, shouldBroadcast); - break; + switch (templateId) { + case TradeDecoder.TEMPLATE_ID : + processTrade(buffer, offset, actingBlockLength, actingVersion, shouldBroadcast); + break; - case QuoteDecoder.TEMPLATE_ID: - processQuote(buffer, offset, actingBlockLength, actingVersion, shouldBroadcast); - break; + case QuoteDecoder.TEMPLATE_ID : + processQuote(buffer, offset, actingBlockLength, actingVersion, shouldBroadcast); + break; - case MarketDepthDecoder.TEMPLATE_ID: - processMarketDepth(buffer, offset, actingBlockLength, actingVersion, shouldBroadcast); - break; + case MarketDepthDecoder.TEMPLATE_ID : + processMarketDepth(buffer, offset, actingBlockLength, actingVersion, shouldBroadcast); + break; - case OrderAckDecoder.TEMPLATE_ID: - processOrderAck(buffer, offset, actingBlockLength, actingVersion, shouldBroadcast); - break; + case OrderAckDecoder.TEMPLATE_ID : + processOrderAck(buffer, offset, actingBlockLength, actingVersion, shouldBroadcast); + break; - case HeartbeatDecoder.TEMPLATE_ID: - processHeartbeat(buffer, offset, actingBlockLength, actingVersion); - break; + case HeartbeatDecoder.TEMPLATE_ID : + processHeartbeat(buffer, offset, actingBlockLength, actingVersion); + break; - default: - LOGGER.warning("Unknown message template ID: " + templateId); - } + default : + LOGGER.warning("Unknown message template ID: " + templateId); + } - messagesProcessed++; - if (shouldBroadcast) { - messagesBroadcast++; - } - logStatistics(); + messagesProcessed++; + if (shouldBroadcast) { + messagesBroadcast++; + } + logStatistics(); - } catch (Exception e) { - LOGGER.log(Level.SEVERE, "Error processing fragment", e); + } catch (Exception e) { + LOGGER.log(Level.SEVERE, "Error processing fragment", e); + } } - } - /** Process Trade message using SBE decoder (zero-copy) */ - private void processTrade( - DirectBuffer buffer, int offset, int blockLength, int version, boolean shouldBroadcast) { - if (!shouldBroadcast) { - return; + /** Process Trade message using SBE decoder (zero-copy) */ + private void processTrade(DirectBuffer buffer, int offset, int blockLength, int version, boolean shouldBroadcast) { + if (!shouldBroadcast) { + return; + } + + tradeDecoder.wrap(buffer, offset, blockLength, version); + + final long timestamp = tradeDecoder.timestamp(); + final long tradeId = tradeDecoder.tradeId(); + final long price = tradeDecoder.price(); + final long quantity = tradeDecoder.quantity(); + final Side side = tradeDecoder.side(); + + final int symbolLength = tradeDecoder.symbolLength(); + tradeDecoder.getSymbol(symbolBuffer, 0, symbolLength); + final String symbol = new String(symbolBuffer, 0, symbolLength); + + sb.setLength(0); + sb.append("{\"type\":\"trade\",\"timestamp\":") + .append(timestamp) + .append(",\"tradeId\":") + .append(tradeId) + .append(",\"symbol\":\"") + .append(symbol) + .append("\"") + .append(",\"price\":") + .append(price / 10000.0) + .append(",\"quantity\":") + .append(quantity) + .append(",\"side\":\"") + .append(side) + .append("\"}"); + + broadcaster.broadcast(sb.toString()); } - tradeDecoder.wrap(buffer, offset, blockLength, version); - - final long timestamp = tradeDecoder.timestamp(); - final long tradeId = tradeDecoder.tradeId(); - final long price = tradeDecoder.price(); - final long quantity = tradeDecoder.quantity(); - final Side side = tradeDecoder.side(); - - final int symbolLength = tradeDecoder.symbolLength(); - tradeDecoder.getSymbol(symbolBuffer, 0, symbolLength); - final String symbol = new String(symbolBuffer, 0, symbolLength); - - sb.setLength(0); - sb.append("{\"type\":\"trade\",\"timestamp\":") - .append(timestamp) - .append(",\"tradeId\":") - .append(tradeId) - .append(",\"symbol\":\"") - .append(symbol) - .append("\"") - .append(",\"price\":") - .append(price / 10000.0) - .append(",\"quantity\":") - .append(quantity) - .append(",\"side\":\"") - .append(side) - .append("\"}"); - - broadcaster.broadcast(sb.toString()); - } - - /** Process Quote message using SBE decoder (zero-copy) */ - private void processQuote( - DirectBuffer buffer, int offset, int blockLength, int version, boolean shouldBroadcast) { - if (!shouldBroadcast) { - return; + /** Process Quote message using SBE decoder (zero-copy) */ + private void processQuote(DirectBuffer buffer, int offset, int blockLength, int version, boolean shouldBroadcast) { + if (!shouldBroadcast) { + return; + } + + quoteDecoder.wrap(buffer, offset, blockLength, version); + + final long timestamp = quoteDecoder.timestamp(); + final long bidPrice = quoteDecoder.bidPrice(); + final long bidSize = quoteDecoder.bidSize(); + final long askPrice = quoteDecoder.askPrice(); + final long askSize = quoteDecoder.askSize(); + + final int symbolLength = quoteDecoder.symbolLength(); + quoteDecoder.getSymbol(symbolBuffer, 0, symbolLength); + final String symbol = new String(symbolBuffer, 0, symbolLength); + + sb.setLength(0); + sb.append("{\"type\":\"quote\",\"timestamp\":") + .append(timestamp) + .append(",\"symbol\":\"") + .append(symbol) + .append("\"") + .append(",\"bid\":{\"price\":") + .append(bidPrice / 10000.0) + .append(",\"size\":") + .append(bidSize) + .append("}") + .append(",\"ask\":{\"price\":") + .append(askPrice / 10000.0) + .append(",\"size\":") + .append(askSize) + .append("}}"); + + broadcaster.broadcast(sb.toString()); } - quoteDecoder.wrap(buffer, offset, blockLength, version); - - final long timestamp = quoteDecoder.timestamp(); - final long bidPrice = quoteDecoder.bidPrice(); - final long bidSize = quoteDecoder.bidSize(); - final long askPrice = quoteDecoder.askPrice(); - final long askSize = quoteDecoder.askSize(); - - final int symbolLength = quoteDecoder.symbolLength(); - quoteDecoder.getSymbol(symbolBuffer, 0, symbolLength); - final String symbol = new String(symbolBuffer, 0, symbolLength); - - sb.setLength(0); - sb.append("{\"type\":\"quote\",\"timestamp\":") - .append(timestamp) - .append(",\"symbol\":\"") - .append(symbol) - .append("\"") - .append(",\"bid\":{\"price\":") - .append(bidPrice / 10000.0) - .append(",\"size\":") - .append(bidSize) - .append("}") - .append(",\"ask\":{\"price\":") - .append(askPrice / 10000.0) - .append(",\"size\":") - .append(askSize) - .append("}}"); - - broadcaster.broadcast(sb.toString()); - } - - /** Process MarketDepth message with repeating groups (zero-copy) */ - private void processMarketDepth( - DirectBuffer buffer, int offset, int blockLength, int version, boolean shouldBroadcast) { - if (!shouldBroadcast) { - return; + /** Process MarketDepth message with repeating groups (zero-copy) */ + private void processMarketDepth(DirectBuffer buffer, int offset, int blockLength, int version, boolean shouldBroadcast) { + if (!shouldBroadcast) { + return; + } + + marketDepthDecoder.wrap(buffer, offset, blockLength, version); + + final long timestamp = marketDepthDecoder.timestamp(); + final long sequenceNumber = marketDepthDecoder.sequenceNumber(); + + sb.setLength(0); + sb.append("{\"type\":\"depth\",\"timestamp\":").append(timestamp).append(",\"sequence\":").append(sequenceNumber).append(",\"bids\":["); + + MarketDepthDecoder.BidsDecoder bids = marketDepthDecoder.bids(); + int bidCount = 0; + while (bids.hasNext()) { + bids.next(); + if (bidCount > 0) + sb.append(","); + sb.append("{\"price\":").append(bids.price() / 10000.0).append(",\"quantity\":").append(bids.quantity()).append("}"); + bidCount++; + } + sb.append("],\"asks\":["); + + MarketDepthDecoder.AsksDecoder asks = marketDepthDecoder.asks(); + int askCount = 0; + while (asks.hasNext()) { + asks.next(); + if (askCount > 0) + sb.append(","); + sb.append("{\"price\":").append(asks.price() / 10000.0).append(",\"quantity\":").append(asks.quantity()).append("}"); + askCount++; + } + sb.append("]"); + + final int symbolLength = marketDepthDecoder.symbolLength(); + marketDepthDecoder.getSymbol(symbolBuffer, 0, symbolLength); + final String symbol = new String(symbolBuffer, 0, symbolLength); + + sb.append(",\"symbol\":\"").append(symbol).append("\"}"); + + broadcaster.broadcast(sb.toString()); } - marketDepthDecoder.wrap(buffer, offset, blockLength, version); - - final long timestamp = marketDepthDecoder.timestamp(); - final long sequenceNumber = marketDepthDecoder.sequenceNumber(); - - sb.setLength(0); - sb.append("{\"type\":\"depth\",\"timestamp\":") - .append(timestamp) - .append(",\"sequence\":") - .append(sequenceNumber) - .append(",\"bids\":["); - - MarketDepthDecoder.BidsDecoder bids = marketDepthDecoder.bids(); - int bidCount = 0; - while (bids.hasNext()) { - bids.next(); - if (bidCount > 0) sb.append(","); - sb.append("{\"price\":") - .append(bids.price() / 10000.0) - .append(",\"quantity\":") - .append(bids.quantity()) - .append("}"); - bidCount++; + private void processOrderAck(DirectBuffer buffer, int offset, int blockLength, int version, boolean shouldBroadcast) { + if (!shouldBroadcast) { + return; + } + + orderAckDecoder.wrap(buffer, offset, blockLength, version); + + final long timestamp = orderAckDecoder.timestamp(); + final long orderId = orderAckDecoder.orderId(); + final long clientOrderId = orderAckDecoder.clientOrderId(); + final Side side = orderAckDecoder.side(); + final OrderType orderType = orderAckDecoder.orderType(); + final long price = orderAckDecoder.price(); + final long quantity = orderAckDecoder.quantity(); + final ExecType execType = orderAckDecoder.execType(); + final long leavesQty = orderAckDecoder.leavesQty(); + final long cumQty = orderAckDecoder.cumQty(); + + final int symbolLength = orderAckDecoder.symbolLength(); + orderAckDecoder.getSymbol(symbolBuffer, 0, symbolLength); + final String symbol = new String(symbolBuffer, 0, symbolLength); + + sb.setLength(0); + sb.append("{\"type\":\"orderAck\",\"timestamp\":") + .append(timestamp) + .append(",\"orderId\":") + .append(orderId) + .append(",\"clientOrderId\":") + .append(clientOrderId) + .append(",\"symbol\":\"") + .append(symbol) + .append("\"") + .append(",\"side\":\"") + .append(side) + .append("\"") + .append(",\"orderType\":\"") + .append(orderType) + .append("\"") + .append(",\"price\":") + .append(price / 10000.0) + .append(",\"quantity\":") + .append(quantity) + .append(",\"execType\":\"") + .append(execType) + .append("\"") + .append(",\"leavesQty\":") + .append(leavesQty) + .append(",\"cumQty\":") + .append(cumQty) + .append("}"); + + broadcaster.broadcast(sb.toString()); } - sb.append("],\"asks\":["); - - MarketDepthDecoder.AsksDecoder asks = marketDepthDecoder.asks(); - int askCount = 0; - while (asks.hasNext()) { - asks.next(); - if (askCount > 0) sb.append(","); - sb.append("{\"price\":") - .append(asks.price() / 10000.0) - .append(",\"quantity\":") - .append(asks.quantity()) - .append("}"); - askCount++; - } - sb.append("]"); - - final int symbolLength = marketDepthDecoder.symbolLength(); - marketDepthDecoder.getSymbol(symbolBuffer, 0, symbolLength); - final String symbol = new String(symbolBuffer, 0, symbolLength); - sb.append(",\"symbol\":\"").append(symbol).append("\"}"); + private void processHeartbeat(DirectBuffer buffer, int offset, int blockLength, int version) { + heartbeatDecoder.wrap(buffer, offset, blockLength, version); - broadcaster.broadcast(sb.toString()); - } + final long timestamp = heartbeatDecoder.timestamp(); + final long sequenceNumber = heartbeatDecoder.sequenceNumber(); - private void processOrderAck( - DirectBuffer buffer, int offset, int blockLength, int version, boolean shouldBroadcast) { - if (!shouldBroadcast) { - return; + if (LOGGER.isLoggable(Level.FINE)) { + LOGGER.fine("Heartbeat: timestamp=" + timestamp + ", seq=" + sequenceNumber); + } } - orderAckDecoder.wrap(buffer, offset, blockLength, version); - - final long timestamp = orderAckDecoder.timestamp(); - final long orderId = orderAckDecoder.orderId(); - final long clientOrderId = orderAckDecoder.clientOrderId(); - final Side side = orderAckDecoder.side(); - final OrderType orderType = orderAckDecoder.orderType(); - final long price = orderAckDecoder.price(); - final long quantity = orderAckDecoder.quantity(); - final ExecType execType = orderAckDecoder.execType(); - final long leavesQty = orderAckDecoder.leavesQty(); - final long cumQty = orderAckDecoder.cumQty(); - - final int symbolLength = orderAckDecoder.symbolLength(); - orderAckDecoder.getSymbol(symbolBuffer, 0, symbolLength); - final String symbol = new String(symbolBuffer, 0, symbolLength); - - sb.setLength(0); - sb.append("{\"type\":\"orderAck\",\"timestamp\":") - .append(timestamp) - .append(",\"orderId\":") - .append(orderId) - .append(",\"clientOrderId\":") - .append(clientOrderId) - .append(",\"symbol\":\"") - .append(symbol) - .append("\"") - .append(",\"side\":\"") - .append(side) - .append("\"") - .append(",\"orderType\":\"") - .append(orderType) - .append("\"") - .append(",\"price\":") - .append(price / 10000.0) - .append(",\"quantity\":") - .append(quantity) - .append(",\"execType\":\"") - .append(execType) - .append("\"") - .append(",\"leavesQty\":") - .append(leavesQty) - .append(",\"cumQty\":") - .append(cumQty) - .append("}"); - - broadcaster.broadcast(sb.toString()); - } - - private void processHeartbeat(DirectBuffer buffer, int offset, int blockLength, int version) { - heartbeatDecoder.wrap(buffer, offset, blockLength, version); - - final long timestamp = heartbeatDecoder.timestamp(); - final long sequenceNumber = heartbeatDecoder.sequenceNumber(); - - if (LOGGER.isLoggable(Level.FINE)) { - LOGGER.fine("Heartbeat: timestamp=" + timestamp + ", seq=" + sequenceNumber); - } - } - - private void logStatistics() { - long now = System.currentTimeMillis(); - if (now - lastLogTime > 5000) { - double elapsedSeconds = (now - lastLogTime) / 1000.0; - LOGGER.info( - String.format( - "FragmentHandler Stats - Processed: %,d (%.0f msg/sec) | Broadcast to UI: %,d (%.0f msg/sec) | Sample rate: 1 in %d", - messagesProcessed, - messagesProcessed / elapsedSeconds, - messagesBroadcast, - messagesBroadcast / elapsedSeconds, - SAMPLE_RATE)); - lastLogTime = now; - messagesProcessed = 0; - messagesBroadcast = 0; + private void logStatistics() { + long now = System.currentTimeMillis(); + if (now - lastLogTime > 5000) { + double elapsedSeconds = (now - lastLogTime) / 1000.0; + LOGGER.info(String.format("FragmentHandler Stats - Processed: %,d (%.0f msg/sec) | Broadcast to UI: %,d (%.0f msg/sec) | Sample rate: 1 in %d", + messagesProcessed, messagesProcessed / elapsedSeconds, messagesBroadcast, messagesBroadcast / elapsedSeconds, SAMPLE_RATE)); + lastLogTime = now; + messagesProcessed = 0; + messagesBroadcast = 0; + } } - } } diff --git a/src/main/java/fish/payara/trader/aeron/MarketDataPublisher.java b/src/main/java/fish/payara/trader/aeron/MarketDataPublisher.java index b4c30b4..dd3dfd6 100644 --- a/src/main/java/fish/payara/trader/aeron/MarketDataPublisher.java +++ b/src/main/java/fish/payara/trader/aeron/MarketDataPublisher.java @@ -26,605 +26,546 @@ import org.eclipse.microprofile.config.inject.ConfigProperty; /** - * Market Data Publisher Simulator Generates synthetic market data and publishes via Aeron using SBE - * encoding. This simulates a high-frequency data feed for testing the ingestion pipeline. - * IMPORTANT: Depends on AeronSubscriberBean to initialize first (provides MediaDriver) + * Market Data Publisher Simulator Generates synthetic market data and publishes via Aeron using SBE encoding. This simulates a high-frequency data feed for + * testing the ingestion pipeline. IMPORTANT: Depends on AeronSubscriberBean to initialize first (provides MediaDriver) */ @ApplicationScoped public class MarketDataPublisher { - private static final Logger LOGGER = Logger.getLogger(MarketDataPublisher.class.getName()); - - private static final String CHANNEL = "aeron:ipc"; - private static final int STREAM_ID = 1001; - private static final int BUFFER_SIZE = 4096; - private static final int SAMPLE_RATE = 50; // Broadcast 1 in 50 messages to prevent flooding - - private static final String[] SYMBOLS = { - "AAPL", "GOOGL", "MSFT", "AMZN", "TSLA", "NVDA", "META", "NFLX" - }; - - private Aeron aeron; - private Publication publication; - - // SBE encoders (reusable flyweights) - private final MessageHeaderEncoder headerEncoder = new MessageHeaderEncoder(); - private final TradeEncoder tradeEncoder = new TradeEncoder(); - private final QuoteEncoder quoteEncoder = new QuoteEncoder(); - private final MarketDepthEncoder marketDepthEncoder = new MarketDepthEncoder(); - private final HeartbeatEncoder heartbeatEncoder = new HeartbeatEncoder(); - - private UnsafeBuffer buffer; - - private final AtomicLong sequenceNumber = new AtomicLong(0); - private final AtomicLong tradeIdGenerator = new AtomicLong(1000); - - private final AtomicLong messagesPublished = new AtomicLong(0); - private final AtomicInteger consecutiveFailures = new AtomicInteger(0); - private static final int MAX_CONSECUTIVE_FAILURES = 50; - private long sampleCounter = 0; - private volatile boolean initialized = false; - private volatile boolean running = false; - private boolean isDirectMode; - private long lastWarningLogTime = 0; - private static final long WARNING_LOG_INTERVAL_MS = - 5000; // Log warnings at most once per 5 seconds - - private Future publisherFuture; - private Future statsFuture; - - @Inject private AeronSubscriberBean subscriberBean; - - @Inject - @ConfigProperty(name = "TRADER_INGESTION_MODE", defaultValue = "AERON") - private String ingestionMode; - - @Inject - @ConfigProperty(name = "ENABLE_PUBLISHER", defaultValue = "true") - String enablePublisherEnv; - - @Inject private MarketDataBroadcaster broadcaster; - - @Inject private HazelcastInstance hazelcastInstance; - - @Inject @VirtualThreadExecutor private ManagedExecutorService managedExecutorService; - - // Cluster-wide message counter (shared across all instances) - private IAtomicLong clusterMessageCounter; - - void contextInitialized(@Observes @Initialized(ApplicationScoped.class) Object event) { - // managedExecutorService.submit(this::init); - init(); - } - - public void init() { - // Initialize cluster-wide message counter on ALL instances (even non-publishers) - // This allows all instances to read the shared counter value via Hazelcast CP Subsystem - if (hazelcastInstance != null) { - try { - clusterMessageCounter = - hazelcastInstance.getCPSubsystem().getAtomicLong("cluster-message-count"); - LOGGER.info("Initialized cluster-wide message counter"); - } catch (Exception e) { - LOGGER.log( - Level.WARNING, - "Failed to initialize cluster message counter, using local counter only", - e); - } - } + private static final Logger LOGGER = Logger.getLogger(MarketDataPublisher.class.getName()); - // Check if publisher should be enabled on this instance - if (enablePublisherEnv != null && !"true".equalsIgnoreCase(enablePublisherEnv)) { - LOGGER.info( - "Market Data Publisher DISABLED on this instance (ENABLE_PUBLISHER=" - + enablePublisherEnv - + ")"); - LOGGER.info("This instance will only consume messages from the cluster topic via Hazelcast"); - return; - } + private static final String CHANNEL = "aeron:ipc"; + private static final int STREAM_ID = 1001; + private static final int BUFFER_SIZE = 4096; + private static final int SAMPLE_RATE = 50; // Broadcast 1 in 50 messages to prevent flooding + + private static final String[] SYMBOLS = {"AAPL", "GOOGL", "MSFT", "AMZN", "TSLA", "NVDA", "META", "NFLX"}; + + private Aeron aeron; + private Publication publication; + + // SBE encoders (reusable flyweights) + private final MessageHeaderEncoder headerEncoder = new MessageHeaderEncoder(); + private final TradeEncoder tradeEncoder = new TradeEncoder(); + private final QuoteEncoder quoteEncoder = new QuoteEncoder(); + private final MarketDepthEncoder marketDepthEncoder = new MarketDepthEncoder(); + private final HeartbeatEncoder heartbeatEncoder = new HeartbeatEncoder(); + + private UnsafeBuffer buffer; + + private final AtomicLong sequenceNumber = new AtomicLong(0); + private final AtomicLong tradeIdGenerator = new AtomicLong(1000); + + private final AtomicLong messagesPublished = new AtomicLong(0); + private final AtomicInteger consecutiveFailures = new AtomicInteger(0); + private static final int MAX_CONSECUTIVE_FAILURES = 50; + private long sampleCounter = 0; + private volatile boolean initialized = false; + private volatile boolean running = false; + private boolean isDirectMode; + private long lastWarningLogTime = 0; + private static final long WARNING_LOG_INTERVAL_MS = 5000; // Log warnings at most once per 5 seconds + + private Future publisherFuture; + private Future statsFuture; + + @Inject + private AeronSubscriberBean subscriberBean; + + @Inject + @ConfigProperty(name = "TRADER_INGESTION_MODE", defaultValue = "AERON") + private String ingestionMode; + + @Inject + @ConfigProperty(name = "ENABLE_PUBLISHER", defaultValue = "true") + String enablePublisherEnv; - LOGGER.info( - "Initializing Market Data Publisher (ENABLE_PUBLISHER=" - + enablePublisherEnv - + "). Mode: " - + ingestionMode); + @Inject + private MarketDataBroadcaster broadcaster; - if ("DIRECT".equalsIgnoreCase(ingestionMode)) { - LOGGER.info("Running in DIRECT mode - Bypassing Aeron/SBE setup."); - initialized = true; - isDirectMode = true; + @Inject + private HazelcastInstance hazelcastInstance; - startPublishing(); - return; + @Inject + @VirtualThreadExecutor + private ManagedExecutorService managedExecutorService; + + // Cluster-wide message counter (shared across all instances) + private IAtomicLong clusterMessageCounter; + + void contextInitialized(@Observes @Initialized(ApplicationScoped.class) Object event) { + // managedExecutorService.submit(this::init); + init(); } - // Initialize buffer only if in AERON mode - this.buffer = new UnsafeBuffer(ByteBuffer.allocateDirect(BUFFER_SIZE)); - - try { - // Wait for AeronSubscriberBean to be ready (both observers fire at roughly same time) - LOGGER.info("Waiting for AeronSubscriberBean to be ready..."); - int waitAttempts = 0; - while (!subscriberBean.isReady() && waitAttempts < 60) { - Thread.sleep(500); - waitAttempts++; - } - - if (!subscriberBean.isReady()) { - LOGGER.severe("AeronSubscriberBean did not become ready in time"); - return; - } - - String aeronDir = subscriberBean.getAeronDirectoryName(); - LOGGER.info("Connecting to embedded MediaDriver at: " + aeronDir); - - aeron = - Aeron.connect( - new Aeron.Context() - .aeronDirectoryName(aeronDir) - .errorHandler( - throwable -> LOGGER.log(Level.SEVERE, "Aeron publisher error", throwable))); - - LOGGER.info("Connected to Aeron. Adding publication..."); - - publication = aeron.addPublication(CHANNEL, STREAM_ID); - - LOGGER.info("Waiting for subscriber to connect to publication..."); - int attempts = 0; - while (!publication.isConnected() && attempts < 20) { - Thread.sleep(500); - attempts++; - } - - if (publication.isConnected()) { - LOGGER.info("Market Data Publisher initialized successfully"); - initialized = true; - - startPublishing(); - } else { - LOGGER.warning("Publisher not connected after waiting"); - } - - } catch (Exception e) { - LOGGER.log(Level.SEVERE, "Failed to initialize publisher", e); + public void init() { + // Initialize cluster-wide message counter on ALL instances (even non-publishers) + // This allows all instances to read the shared counter value via Hazelcast CP Subsystem + if (hazelcastInstance != null) { + try { + clusterMessageCounter = hazelcastInstance.getCPSubsystem().getAtomicLong("cluster-message-count"); + LOGGER.info("Initialized cluster-wide message counter"); + } catch (Exception e) { + LOGGER.log(Level.WARNING, "Failed to initialize cluster message counter, using local counter only", e); + } + } + + // Check if publisher should be enabled on this instance + if (enablePublisherEnv != null && !"true".equalsIgnoreCase(enablePublisherEnv)) { + LOGGER.info("Market Data Publisher DISABLED on this instance (ENABLE_PUBLISHER=" + enablePublisherEnv + ")"); + LOGGER.info("This instance will only consume messages from the cluster topic via Hazelcast"); + return; + } + + LOGGER.info("Initializing Market Data Publisher (ENABLE_PUBLISHER=" + enablePublisherEnv + "). Mode: " + ingestionMode); + + if ("DIRECT".equalsIgnoreCase(ingestionMode)) { + LOGGER.info("Running in DIRECT mode - Bypassing Aeron/SBE setup."); + initialized = true; + isDirectMode = true; + + startPublishing(); + return; + } + + // Initialize buffer only if in AERON mode + this.buffer = new UnsafeBuffer(ByteBuffer.allocateDirect(BUFFER_SIZE)); + + try { + // Wait for AeronSubscriberBean to be ready (both observers fire at roughly same time) + LOGGER.info("Waiting for AeronSubscriberBean to be ready..."); + int waitAttempts = 0; + while (!subscriberBean.isReady() && waitAttempts < 60) { + Thread.sleep(500); + waitAttempts++; + } + + if (!subscriberBean.isReady()) { + LOGGER.severe("AeronSubscriberBean did not become ready in time"); + return; + } + + String aeronDir = subscriberBean.getAeronDirectoryName(); + LOGGER.info("Connecting to embedded MediaDriver at: " + aeronDir); + + aeron = Aeron.connect(new Aeron.Context().aeronDirectoryName(aeronDir) + .errorHandler(throwable -> LOGGER.log(Level.SEVERE, "Aeron publisher error", throwable))); + + LOGGER.info("Connected to Aeron. Adding publication..."); + + publication = aeron.addPublication(CHANNEL, STREAM_ID); + + LOGGER.info("Waiting for subscriber to connect to publication..."); + int attempts = 0; + while (!publication.isConnected() && attempts < 20) { + Thread.sleep(500); + attempts++; + } + + if (publication.isConnected()) { + LOGGER.info("Market Data Publisher initialized successfully"); + initialized = true; + + startPublishing(); + } else { + LOGGER.warning("Publisher not connected after waiting"); + } + + } catch (Exception e) { + LOGGER.log(Level.SEVERE, "Failed to initialize publisher", e); + } } - } - /** Start background thread to continuously publish market data at high throughput */ - private void startPublishing() { - running = true; - publisherFuture = - managedExecutorService.submit( - () -> { - LOGGER.info( - "Market data publisher task started - targeting 50k-100k messages/sec with burst patterns"); + /** Start background thread to continuously publish market data at high throughput */ + private void startPublishing() { + running = true; + publisherFuture = managedExecutorService.submit(() -> { + LOGGER.info("Market data publisher task started - targeting 50k-100k messages/sec with burst patterns"); - final int BASE_BURST_SIZE = 500; - final long PARK_NANOS = 5_000; // 5 microseconds base rate + final int BASE_BURST_SIZE = 500; + final long PARK_NANOS = 5_000; // 5 microseconds base rate - while (running && !Thread.currentThread().isInterrupted()) { + while (running && !Thread.currentThread().isInterrupted()) { try { - // Apply burst multiplier for realistic market spikes - int burstMultiplier = getBurstMultiplier(); - int adjustedBurstSize = BASE_BURST_SIZE * burstMultiplier; - - // Log burst transitions - if (burstMultiplier > 1) { - long secondOfMinute = (System.currentTimeMillis() / 1000) % 60; - if (secondOfMinute == 20 || secondOfMinute == 45) { - LOGGER.info("BURST MODE: " + burstMultiplier + "x allocation spike started"); + // Apply burst multiplier for realistic market spikes + int burstMultiplier = getBurstMultiplier(); + int adjustedBurstSize = BASE_BURST_SIZE * burstMultiplier; + + // Log burst transitions + if (burstMultiplier > 1) { + long secondOfMinute = (System.currentTimeMillis() / 1000) % 60; + if (secondOfMinute == 20 || secondOfMinute == 45) { + LOGGER.info("BURST MODE: " + burstMultiplier + "x allocation spike started"); + } } - } - for (int i = 0; i < adjustedBurstSize && running; i++) { - publishTrade(); - publishQuote(); - publishMarketDepth(); + for (int i = 0; i < adjustedBurstSize && running; i++) { + publishTrade(); + publishQuote(); + publishMarketDepth(); - if (i % 100 == 0) { - publishHeartbeat(); + if (i % 100 == 0) { + publishHeartbeat(); + } } - } - LockSupport.parkNanos(PARK_NANOS); + LockSupport.parkNanos(PARK_NANOS); } catch (Throwable e) { - LOGGER.log(Level.SEVERE, "Critical error in publisher loop", e); - // Brief pause on error, then continue - LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(100)); + LOGGER.log(Level.SEVERE, "Critical error in publisher loop", e); + // Brief pause on error, then continue + LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(100)); } - } + } - LOGGER.info("Market data publisher task stopped"); - }); + LOGGER.info("Market data publisher task stopped"); + }); - statsFuture = - managedExecutorService.submit( - () -> { - long lastCount = 0; - long lastTime = System.currentTimeMillis(); + statsFuture = managedExecutorService.submit(() -> { + long lastCount = 0; + long lastTime = System.currentTimeMillis(); - while (running && !Thread.currentThread().isInterrupted()) { + while (running && !Thread.currentThread().isInterrupted()) { try { - Thread.sleep(5000); - - long currentCount = messagesPublished.get(); - long currentTime = System.currentTimeMillis(); - long messagesSinceLastLog = currentCount - lastCount; - double elapsedSeconds = (currentTime - lastTime) / 1000.0; - double messagesPerSecond = messagesSinceLastLog / elapsedSeconds; - - int currentMultiplier = getBurstMultiplier(); - String burstStatus = - currentMultiplier > 1 ? " [BURST: " + currentMultiplier + "x]" : ""; - - LOGGER.info( - String.format( - "Publisher Stats - Total: %,d | Last 5s: %,d (%.0f msg/sec)%s", - currentCount, messagesSinceLastLog, messagesPerSecond, burstStatus)); - - String statsJson = - String.format( - "{\"type\":\"stats\",\"total\":%d,\"rate\":%.0f,\"burstMultiplier\":%d}", - currentCount, messagesPerSecond, currentMultiplier); - broadcaster.broadcast(statsJson); - - lastCount = currentCount; - lastTime = currentTime; + Thread.sleep(5000); + + long currentCount = messagesPublished.get(); + long currentTime = System.currentTimeMillis(); + long messagesSinceLastLog = currentCount - lastCount; + double elapsedSeconds = (currentTime - lastTime) / 1000.0; + double messagesPerSecond = messagesSinceLastLog / elapsedSeconds; + + int currentMultiplier = getBurstMultiplier(); + String burstStatus = currentMultiplier > 1 ? " [BURST: " + currentMultiplier + "x]" : ""; + + LOGGER.info(String.format("Publisher Stats - Total: %,d | Last 5s: %,d (%.0f msg/sec)%s", currentCount, messagesSinceLastLog, + messagesPerSecond, burstStatus)); + + String statsJson = String.format("{\"type\":\"stats\",\"total\":%d,\"rate\":%.0f,\"burstMultiplier\":%d}", currentCount, messagesPerSecond, + currentMultiplier); + broadcaster.broadcast(statsJson); + + lastCount = currentCount; + lastTime = currentTime; } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - break; + Thread.currentThread().interrupt(); + break; } - } - }); - } - - /** - * Calculate burst multiplier based on time pattern simulating market events - * - *

Pattern (60-second cycle): - 00-20s: Normal trading (1x) - 20-25s: News event burst (5x - * allocation spike) - 25-45s: Normal trading (1x) - 45-50s: Market close spike (3x allocation) - - * 50-60s: Normal trading (1x) - */ - private int getBurstMultiplier() { - long secondOfMinute = (System.currentTimeMillis() / 1000) % 60; - - if (secondOfMinute >= 20 && secondOfMinute < 25) { - return 5; // News event - 5x burst - } else if (secondOfMinute >= 45 && secondOfMinute < 50) { - return 3; // Market close - 3x spike + } + }); } - return 1; // Normal - } - - /** Publish a Trade message */ - private void publishTrade() { - if (isDirectMode) { - final ThreadLocalRandom currentRandom = ThreadLocalRandom.current(); // Use ThreadLocalRandom - final String symbol = SYMBOLS[currentRandom.nextInt(SYMBOLS.length)]; - final double price = 100.0 + currentRandom.nextDouble() * 400.0; - final int quantity = currentRandom.nextInt(1000) + 100; - final String side = currentRandom.nextBoolean() ? "BUY" : "SELL"; - - String json = - String.format( - "{\"type\":\"trade\",\"timestamp\":%d,\"tradeId\":%d,\"symbol\":\"%s\",\"price\":%.4f,\"quantity\":%d,\"side\":\"%s\"}", - System.currentTimeMillis(), - tradeIdGenerator.incrementAndGet(), - symbol, - price, - quantity, - side); - if (++sampleCounter % SAMPLE_RATE == 0) { - broadcaster.broadcastWithArtificialLoad(json); - } - messagesPublished.incrementAndGet(); - // Increment cluster-wide counter - if (clusterMessageCounter != null) { - try { - clusterMessageCounter.incrementAndGet(); - } catch (Exception e) { - // Ignore cluster counter errors, not critical + /** + * Calculate burst multiplier based on time pattern simulating market events + * + *

+ * Pattern (60-second cycle): - 00-20s: Normal trading (1x) - 20-25s: News event burst (5x allocation spike) - 25-45s: Normal trading (1x) - 45-50s: Market + * close spike (3x allocation) - 50-60s: Normal trading (1x) + */ + private int getBurstMultiplier() { + long secondOfMinute = (System.currentTimeMillis() / 1000) % 60; + + if (secondOfMinute >= 20 && secondOfMinute < 25) { + return 5; // News event - 5x burst + } else if (secondOfMinute >= 45 && secondOfMinute < 50) { + return 3; // Market close - 3x spike } - } - return; + + return 1; // Normal } - int bufferOffset = 0; - final ThreadLocalRandom currentRandom = ThreadLocalRandom.current(); - final String symbol = SYMBOLS[currentRandom.nextInt(SYMBOLS.length)]; - - headerEncoder - .wrap(buffer, bufferOffset) - .blockLength(tradeEncoder.sbeBlockLength()) - .templateId(tradeEncoder.sbeTemplateId()) - .schemaId(tradeEncoder.sbeSchemaId()) - .version(tradeEncoder.sbeSchemaVersion()); - - bufferOffset += headerEncoder.encodedLength(); - - tradeEncoder - .wrap(buffer, bufferOffset) - .timestamp(System.currentTimeMillis()) - .tradeId(tradeIdGenerator.incrementAndGet()) - .price((long) ((100.0 + currentRandom.nextDouble() * 400.0) * 10000)) // $100-$500 - .quantity(currentRandom.nextInt(1000) + 100) - .side(currentRandom.nextBoolean() ? Side.BUY : Side.SELL) - .symbol(symbol); - - final int length = headerEncoder.encodedLength() + tradeEncoder.encodedLength(); - offer(buffer, 0, length, "Trade"); - } - - /** Publish a Quote message */ - private void publishQuote() { - if (isDirectMode) { - final ThreadLocalRandom currentRandom = ThreadLocalRandom.current(); - final String symbol = SYMBOLS[currentRandom.nextInt(SYMBOLS.length)]; - final double basePrice = 100.0 + currentRandom.nextDouble() * 400.0; - final double bidPrice = basePrice - 0.01; - final double askPrice = basePrice + 0.01; - final int bidSize = currentRandom.nextInt(10000) + 100; - final int askSize = currentRandom.nextInt(10000) + 100; - - String json = - String.format( - "{\"type\":\"quote\",\"timestamp\":%d,\"symbol\":\"%s\",\"bid\":{\"price\":%.4f,\"size\":%d},\"ask\":{\"price\":%.4f,\"size\":%d}}", - System.currentTimeMillis(), symbol, bidPrice, bidSize, askPrice, askSize); - if (++sampleCounter % SAMPLE_RATE == 0) { - broadcaster.broadcastWithArtificialLoad(json); - } - messagesPublished.incrementAndGet(); - // Increment cluster-wide counter - if (clusterMessageCounter != null) { - try { - clusterMessageCounter.incrementAndGet(); - } catch (Exception e) { - // Ignore cluster counter errors, not critical + /** Publish a Trade message */ + private void publishTrade() { + if (isDirectMode) { + final ThreadLocalRandom currentRandom = ThreadLocalRandom.current(); // Use ThreadLocalRandom + final String symbol = SYMBOLS[currentRandom.nextInt(SYMBOLS.length)]; + final double price = 100.0 + currentRandom.nextDouble() * 400.0; + final int quantity = currentRandom.nextInt(1000) + 100; + final String side = currentRandom.nextBoolean() ? "BUY" : "SELL"; + + String json = String.format("{\"type\":\"trade\",\"timestamp\":%d,\"tradeId\":%d,\"symbol\":\"%s\",\"price\":%.4f,\"quantity\":%d,\"side\":\"%s\"}", + System.currentTimeMillis(), tradeIdGenerator.incrementAndGet(), symbol, price, quantity, side); + if (++sampleCounter % SAMPLE_RATE == 0) { + broadcaster.broadcastWithArtificialLoad(json); + } + messagesPublished.incrementAndGet(); + // Increment cluster-wide counter + if (clusterMessageCounter != null) { + try { + clusterMessageCounter.incrementAndGet(); + } catch (Exception e) { + // Ignore cluster counter errors, not critical + } + } + return; } - } - return; + + int bufferOffset = 0; + final ThreadLocalRandom currentRandom = ThreadLocalRandom.current(); + final String symbol = SYMBOLS[currentRandom.nextInt(SYMBOLS.length)]; + + headerEncoder.wrap(buffer, bufferOffset) + .blockLength(tradeEncoder.sbeBlockLength()) + .templateId(tradeEncoder.sbeTemplateId()) + .schemaId(tradeEncoder.sbeSchemaId()) + .version(tradeEncoder.sbeSchemaVersion()); + + bufferOffset += headerEncoder.encodedLength(); + + tradeEncoder.wrap(buffer, bufferOffset) + .timestamp(System.currentTimeMillis()) + .tradeId(tradeIdGenerator.incrementAndGet()) + .price((long) ((100.0 + currentRandom.nextDouble() * 400.0) * 10000)) // $100-$500 + .quantity(currentRandom.nextInt(1000) + 100) + .side(currentRandom.nextBoolean() ? Side.BUY : Side.SELL) + .symbol(symbol); + + final int length = headerEncoder.encodedLength() + tradeEncoder.encodedLength(); + offer(buffer, 0, length, "Trade"); } - int bufferOffset = 0; - final ThreadLocalRandom currentRandom = ThreadLocalRandom.current(); - final String symbol = SYMBOLS[currentRandom.nextInt(SYMBOLS.length)]; - final double basePrice = 100.0 + currentRandom.nextDouble() * 400.0; - - headerEncoder - .wrap(buffer, bufferOffset) - .blockLength(quoteEncoder.sbeBlockLength()) - .templateId(quoteEncoder.sbeTemplateId()) - .schemaId(quoteEncoder.sbeSchemaId()) - .version(quoteEncoder.sbeSchemaVersion()); - - bufferOffset += headerEncoder.encodedLength(); - - quoteEncoder - .wrap(buffer, bufferOffset) - .timestamp(System.currentTimeMillis()) - .bidPrice((long) ((basePrice - 0.01) * 10000)) - .bidSize(currentRandom.nextInt(10000) + 100) - .askPrice((long) ((basePrice + 0.01) * 10000)) - .askSize(currentRandom.nextInt(10000) + 100) - .symbol(symbol); - - final int length = headerEncoder.encodedLength() + quoteEncoder.encodedLength(); - offer(buffer, 0, length, "Quote"); - } - - /** Publish a MarketDepth message */ - private void publishMarketDepth() { - if (isDirectMode) { - final ThreadLocalRandom currentRandom = ThreadLocalRandom.current(); - final String symbol = SYMBOLS[currentRandom.nextInt(SYMBOLS.length)]; - final double basePrice = 100.0 + currentRandom.nextDouble() * 400.0; - final long timestamp = System.currentTimeMillis(); - final long seq = sequenceNumber.incrementAndGet(); - - StringBuilder bidsJson = new StringBuilder("["); - for (int i = 0; i < 5; i++) { - if (i > 0) bidsJson.append(","); - bidsJson.append( - String.format( - "{\"price\":%.4f,\"quantity\":%d}", - basePrice - (i + 1) * 0.01, currentRandom.nextInt(5000) + 100)); - } - bidsJson.append("]"); - - StringBuilder asksJson = new StringBuilder("["); - for (int i = 0; i < 5; i++) { - if (i > 0) asksJson.append(","); - asksJson.append( - String.format( - "{\"price\":%.4f,\"quantity\":%d}", - basePrice + (i + 1) * 0.01, currentRandom.nextInt(5000) + 100)); - } - asksJson.append("]"); - - String json = - String.format( - "{\"type\":\"depth\",\"timestamp\":%d,\"symbol\":\"%s\",\"sequence\":%d,\"bids\":%s,\"asks\":%s}", - timestamp, symbol, seq, bidsJson, asksJson); - if (++sampleCounter % SAMPLE_RATE == 0) { - broadcaster.broadcastWithArtificialLoad(json); - } - messagesPublished.incrementAndGet(); - // Increment cluster-wide counter - if (clusterMessageCounter != null) { - try { - clusterMessageCounter.incrementAndGet(); - } catch (Exception e) { - // Ignore cluster counter errors, not critical + /** Publish a Quote message */ + private void publishQuote() { + if (isDirectMode) { + final ThreadLocalRandom currentRandom = ThreadLocalRandom.current(); + final String symbol = SYMBOLS[currentRandom.nextInt(SYMBOLS.length)]; + final double basePrice = 100.0 + currentRandom.nextDouble() * 400.0; + final double bidPrice = basePrice - 0.01; + final double askPrice = basePrice + 0.01; + final int bidSize = currentRandom.nextInt(10000) + 100; + final int askSize = currentRandom.nextInt(10000) + 100; + + String json = String.format( + "{\"type\":\"quote\",\"timestamp\":%d,\"symbol\":\"%s\",\"bid\":{\"price\":%.4f,\"size\":%d},\"ask\":{\"price\":%.4f,\"size\":%d}}", + System.currentTimeMillis(), symbol, bidPrice, bidSize, askPrice, askSize); + if (++sampleCounter % SAMPLE_RATE == 0) { + broadcaster.broadcastWithArtificialLoad(json); + } + messagesPublished.incrementAndGet(); + // Increment cluster-wide counter + if (clusterMessageCounter != null) { + try { + clusterMessageCounter.incrementAndGet(); + } catch (Exception e) { + // Ignore cluster counter errors, not critical + } + } + return; } - } - return; - } - int bufferOffset = 0; - final ThreadLocalRandom currentRandom = ThreadLocalRandom.current(); - final String symbol = SYMBOLS[currentRandom.nextInt(SYMBOLS.length)]; - final double basePrice = 100.0 + currentRandom.nextDouble() * 400.0; - - headerEncoder - .wrap(buffer, bufferOffset) - .blockLength(marketDepthEncoder.sbeBlockLength()) - .templateId(marketDepthEncoder.sbeTemplateId()) - .schemaId(marketDepthEncoder.sbeSchemaId()) - .version(marketDepthEncoder.sbeSchemaVersion()); - - bufferOffset += headerEncoder.encodedLength(); - - marketDepthEncoder - .wrap(buffer, bufferOffset) - .timestamp(System.currentTimeMillis()) - .sequenceNumber(sequenceNumber.incrementAndGet()); - - MarketDepthEncoder.BidsEncoder bidsEncoder = marketDepthEncoder.bidsCount(5); - for (int i = 0; i < 5; i++) { - bidsEncoder - .next() - .price((long) ((basePrice - (i + 1) * 0.01) * 10000)) - .quantity(currentRandom.nextInt(5000) + 100); + int bufferOffset = 0; + final ThreadLocalRandom currentRandom = ThreadLocalRandom.current(); + final String symbol = SYMBOLS[currentRandom.nextInt(SYMBOLS.length)]; + final double basePrice = 100.0 + currentRandom.nextDouble() * 400.0; + + headerEncoder.wrap(buffer, bufferOffset) + .blockLength(quoteEncoder.sbeBlockLength()) + .templateId(quoteEncoder.sbeTemplateId()) + .schemaId(quoteEncoder.sbeSchemaId()) + .version(quoteEncoder.sbeSchemaVersion()); + + bufferOffset += headerEncoder.encodedLength(); + + quoteEncoder.wrap(buffer, bufferOffset) + .timestamp(System.currentTimeMillis()) + .bidPrice((long) ((basePrice - 0.01) * 10000)) + .bidSize(currentRandom.nextInt(10000) + 100) + .askPrice((long) ((basePrice + 0.01) * 10000)) + .askSize(currentRandom.nextInt(10000) + 100) + .symbol(symbol); + + final int length = headerEncoder.encodedLength() + quoteEncoder.encodedLength(); + offer(buffer, 0, length, "Quote"); } - MarketDepthEncoder.AsksEncoder asksEncoder = marketDepthEncoder.asksCount(5); - for (int i = 0; i < 5; i++) { - asksEncoder - .next() - .price((long) ((basePrice + (i + 1) * 0.01) * 10000)) - .quantity(currentRandom.nextInt(5000) + 100); - } + /** Publish a MarketDepth message */ + private void publishMarketDepth() { + if (isDirectMode) { + final ThreadLocalRandom currentRandom = ThreadLocalRandom.current(); + final String symbol = SYMBOLS[currentRandom.nextInt(SYMBOLS.length)]; + final double basePrice = 100.0 + currentRandom.nextDouble() * 400.0; + final long timestamp = System.currentTimeMillis(); + final long seq = sequenceNumber.incrementAndGet(); + + StringBuilder bidsJson = new StringBuilder("["); + for (int i = 0; i < 5; i++) { + if (i > 0) + bidsJson.append(","); + bidsJson.append(String.format("{\"price\":%.4f,\"quantity\":%d}", basePrice - (i + 1) * 0.01, currentRandom.nextInt(5000) + 100)); + } + bidsJson.append("]"); + + StringBuilder asksJson = new StringBuilder("["); + for (int i = 0; i < 5; i++) { + if (i > 0) + asksJson.append(","); + asksJson.append(String.format("{\"price\":%.4f,\"quantity\":%d}", basePrice + (i + 1) * 0.01, currentRandom.nextInt(5000) + 100)); + } + asksJson.append("]"); + + String json = String.format("{\"type\":\"depth\",\"timestamp\":%d,\"symbol\":\"%s\",\"sequence\":%d,\"bids\":%s,\"asks\":%s}", timestamp, symbol, + seq, bidsJson, asksJson); + if (++sampleCounter % SAMPLE_RATE == 0) { + broadcaster.broadcastWithArtificialLoad(json); + } + messagesPublished.incrementAndGet(); + // Increment cluster-wide counter + if (clusterMessageCounter != null) { + try { + clusterMessageCounter.incrementAndGet(); + } catch (Exception e) { + // Ignore cluster counter errors, not critical + } + } + return; + } - marketDepthEncoder.symbol(symbol); + int bufferOffset = 0; + final ThreadLocalRandom currentRandom = ThreadLocalRandom.current(); + final String symbol = SYMBOLS[currentRandom.nextInt(SYMBOLS.length)]; + final double basePrice = 100.0 + currentRandom.nextDouble() * 400.0; - final int length = headerEncoder.encodedLength() + marketDepthEncoder.encodedLength(); - offer(buffer, 0, length, "MarketDepth"); - } + headerEncoder.wrap(buffer, bufferOffset) + .blockLength(marketDepthEncoder.sbeBlockLength()) + .templateId(marketDepthEncoder.sbeTemplateId()) + .schemaId(marketDepthEncoder.sbeSchemaId()) + .version(marketDepthEncoder.sbeSchemaVersion()); - /** Publish a Heartbeat message */ - private void publishHeartbeat() { - if (isDirectMode) { - // In DIRECT mode, we just increment counter but don't broadcast heartbeats to UI - // as they are mainly for system health checks in the Aeron log - messagesPublished.incrementAndGet(); - return; - } + bufferOffset += headerEncoder.encodedLength(); - int bufferOffset = 0; + marketDepthEncoder.wrap(buffer, bufferOffset).timestamp(System.currentTimeMillis()).sequenceNumber(sequenceNumber.incrementAndGet()); - headerEncoder - .wrap(buffer, bufferOffset) - .blockLength(heartbeatEncoder.sbeBlockLength()) - .templateId(heartbeatEncoder.sbeTemplateId()) - .schemaId(heartbeatEncoder.sbeSchemaId()) - .version(heartbeatEncoder.sbeSchemaVersion()); + MarketDepthEncoder.BidsEncoder bidsEncoder = marketDepthEncoder.bidsCount(5); + for (int i = 0; i < 5; i++) { + bidsEncoder.next().price((long) ((basePrice - (i + 1) * 0.01) * 10000)).quantity(currentRandom.nextInt(5000) + 100); + } - bufferOffset += headerEncoder.encodedLength(); + MarketDepthEncoder.AsksEncoder asksEncoder = marketDepthEncoder.asksCount(5); + for (int i = 0; i < 5; i++) { + asksEncoder.next().price((long) ((basePrice + (i + 1) * 0.01) * 10000)).quantity(currentRandom.nextInt(5000) + 100); + } - heartbeatEncoder - .wrap(buffer, bufferOffset) - .timestamp(System.currentTimeMillis()) - .sequenceNumber(sequenceNumber.get()); + marketDepthEncoder.symbol(symbol); - final int length = headerEncoder.encodedLength() + heartbeatEncoder.encodedLength(); - offer(buffer, 0, length, "Heartbeat"); - } + final int length = headerEncoder.encodedLength() + marketDepthEncoder.encodedLength(); + offer(buffer, 0, length, "MarketDepth"); + } - /** Offer buffer to Aeron publication with retry logic */ - private void offer(UnsafeBuffer buffer, int offset, int length, String messageType) { - long result; - int retries = 3; + /** Publish a Heartbeat message */ + private void publishHeartbeat() { + if (isDirectMode) { + // In DIRECT mode, we just increment counter but don't broadcast heartbeats to UI + // as they are mainly for system health checks in the Aeron log + messagesPublished.incrementAndGet(); + return; + } - while (retries > 0) { - result = publication.offer(buffer, offset, length); + int bufferOffset = 0; - if (result > 0) { - messagesPublished.incrementAndGet(); - // Increment cluster-wide counter - if (clusterMessageCounter != null) { - try { - clusterMessageCounter.incrementAndGet(); - } catch (Exception e) { - // Ignore cluster counter errors, not critical - } + headerEncoder.wrap(buffer, bufferOffset) + .blockLength(heartbeatEncoder.sbeBlockLength()) + .templateId(heartbeatEncoder.sbeTemplateId()) + .schemaId(heartbeatEncoder.sbeSchemaId()) + .version(heartbeatEncoder.sbeSchemaVersion()); + + bufferOffset += headerEncoder.encodedLength(); + + heartbeatEncoder.wrap(buffer, bufferOffset).timestamp(System.currentTimeMillis()).sequenceNumber(sequenceNumber.get()); + + final int length = headerEncoder.encodedLength() + heartbeatEncoder.encodedLength(); + offer(buffer, 0, length, "Heartbeat"); + } + + /** Offer buffer to Aeron publication with retry logic */ + private void offer(UnsafeBuffer buffer, int offset, int length, String messageType) { + long result; + int retries = 3; + + while (retries > 0) { + result = publication.offer(buffer, offset, length); + + if (result > 0) { + messagesPublished.incrementAndGet(); + // Increment cluster-wide counter + if (clusterMessageCounter != null) { + try { + clusterMessageCounter.incrementAndGet(); + } catch (Exception e) { + // Ignore cluster counter errors, not critical + } + } + consecutiveFailures.set(0); + return; + } else if (result == Publication.BACK_PRESSURED) { + continue; + } else if (result == Publication.NOT_CONNECTED) { + logWarningRateLimited("Publication not connected"); + handlePublishFailure(messageType); + return; + } else { + logWarningRateLimited("Offer failed for " + messageType + ": " + result); + handlePublishFailure(messageType); + return; + } } - consecutiveFailures.set(0); - return; - } else if (result == Publication.BACK_PRESSURED) { - continue; - } else if (result == Publication.NOT_CONNECTED) { - logWarningRateLimited("Publication not connected"); - handlePublishFailure(messageType); - return; - } else { - logWarningRateLimited("Offer failed for " + messageType + ": " + result); + + logWarningRateLimited("Failed to publish " + messageType + " after retries"); handlePublishFailure(messageType); - return; - } } - logWarningRateLimited("Failed to publish " + messageType + " after retries"); - handlePublishFailure(messageType); - } - - private void logWarningRateLimited(String message) { - long now = System.currentTimeMillis(); - if (now - lastWarningLogTime >= WARNING_LOG_INTERVAL_MS) { - LOGGER.warning(message); - lastWarningLogTime = now; + private void logWarningRateLimited(String message) { + long now = System.currentTimeMillis(); + if (now - lastWarningLogTime >= WARNING_LOG_INTERVAL_MS) { + LOGGER.warning(message); + lastWarningLogTime = now; + } } - } - - private void handlePublishFailure(String messageType) { - int failures = consecutiveFailures.incrementAndGet(); - if (failures >= MAX_CONSECUTIVE_FAILURES) { - LOGGER.severe( - String.format( - "Circuit breaker triggered: %d consecutive publish failures (last attempted: %s). Stopping message generation.", - failures, messageType)); - running = false; + + private void handlePublishFailure(String messageType) { + int failures = consecutiveFailures.incrementAndGet(); + if (failures >= MAX_CONSECUTIVE_FAILURES) { + LOGGER.severe(String.format("Circuit breaker triggered: %d consecutive publish failures (last attempted: %s). Stopping message generation.", + failures, messageType)); + running = false; + } } - } - @PreDestroy - public void shutdown() { - LOGGER.info("Shutting down Market Data Publisher..."); + @PreDestroy + public void shutdown() { + LOGGER.info("Shutting down Market Data Publisher..."); - running = false; + running = false; - if (publisherFuture != null) { - publisherFuture.cancel(true); - } + if (publisherFuture != null) { + publisherFuture.cancel(true); + } - if (statsFuture != null) { - statsFuture.cancel(true); - } + if (statsFuture != null) { + statsFuture.cancel(true); + } - if (publication != null) { - publication.close(); + if (publication != null) { + publication.close(); + } + if (aeron != null) { + aeron.close(); + } + + LOGGER.info("Market Data Publisher shut down. Total messages published: " + messagesPublished.get()); } - if (aeron != null) { - aeron.close(); + + public long getMessagesPublished() { + return messagesPublished.get(); } - LOGGER.info( - "Market Data Publisher shut down. Total messages published: " + messagesPublished.get()); - } - - public long getMessagesPublished() { - return messagesPublished.get(); - } - - public long getClusterMessagesPublished() { - if (clusterMessageCounter != null) { - try { - return clusterMessageCounter.get(); - } catch (Exception e) { - LOGGER.log(Level.WARNING, "Failed to read cluster message counter", e); - } + public long getClusterMessagesPublished() { + if (clusterMessageCounter != null) { + try { + return clusterMessageCounter.get(); + } catch (Exception e) { + LOGGER.log(Level.WARNING, "Failed to read cluster message counter", e); + } + } + return 0; } - return 0; - } } diff --git a/src/main/java/fish/payara/trader/concurrency/ConcurrencyConfig.java b/src/main/java/fish/payara/trader/concurrency/ConcurrencyConfig.java index 1317877..caab85b 100644 --- a/src/main/java/fish/payara/trader/concurrency/ConcurrencyConfig.java +++ b/src/main/java/fish/payara/trader/concurrency/ConcurrencyConfig.java @@ -4,12 +4,9 @@ import jakarta.enterprise.context.ApplicationScoped; /** - * Configuration for Jakarta Concurrency resources. Defines a ManagedExecutorService that uses - * Virtual Threads (Project Loom). + * Configuration for Jakarta Concurrency resources. Defines a ManagedExecutorService that uses Virtual Threads (Project Loom). */ @ApplicationScoped -@ManagedExecutorDefinition( - name = "java:module/concurrent/VirtualThreadExecutor", - virtual = true, - qualifiers = {VirtualThreadExecutor.class}) -public class ConcurrencyConfig {} +@ManagedExecutorDefinition(name = "java:module/concurrent/VirtualThreadExecutor", virtual = true, qualifiers = {VirtualThreadExecutor.class}) +public class ConcurrencyConfig { +} diff --git a/src/main/java/fish/payara/trader/concurrency/VirtualThreadExecutor.java b/src/main/java/fish/payara/trader/concurrency/VirtualThreadExecutor.java index eb1d993..6cdf766 100644 --- a/src/main/java/fish/payara/trader/concurrency/VirtualThreadExecutor.java +++ b/src/main/java/fish/payara/trader/concurrency/VirtualThreadExecutor.java @@ -14,4 +14,5 @@ @Qualifier @Retention(RUNTIME) @Target({METHOD, FIELD, PARAMETER, TYPE}) -public @interface VirtualThreadExecutor {} +public @interface VirtualThreadExecutor { +} diff --git a/src/main/java/fish/payara/trader/gc/GCStats.java b/src/main/java/fish/payara/trader/gc/GCStats.java index c2da5d6..b0f07ff 100644 --- a/src/main/java/fish/payara/trader/gc/GCStats.java +++ b/src/main/java/fish/payara/trader/gc/GCStats.java @@ -4,154 +4,156 @@ import java.util.Map; public class GCStats { - private String gcName; - private long collectionCount; - private long collectionTime; - private long lastPauseDuration; - private List recentPauses; - private PausePercentiles percentiles; - private long totalMemory; - private long usedMemory; - private long freeMemory; - private Map phaseBreakdown; - - public static class PausePercentiles { - private long p50; - private long p95; - private long p99; - private long p999; - private long max; - - public PausePercentiles() {} - - public PausePercentiles(long p50, long p95, long p99, long p999, long max) { - this.p50 = p50; - this.p95 = p95; - this.p99 = p99; - this.p999 = p999; - this.max = max; + private String gcName; + private long collectionCount; + private long collectionTime; + private long lastPauseDuration; + private List recentPauses; + private PausePercentiles percentiles; + private long totalMemory; + private long usedMemory; + private long freeMemory; + private Map phaseBreakdown; + + public static class PausePercentiles { + private long p50; + private long p95; + private long p99; + private long p999; + private long max; + + public PausePercentiles() { + } + + public PausePercentiles(long p50, long p95, long p99, long p999, long max) { + this.p50 = p50; + this.p95 = p95; + this.p99 = p99; + this.p999 = p999; + this.max = max; + } + + public long getP50() { + return p50; + } + + public void setP50(long p50) { + this.p50 = p50; + } + + public long getP95() { + return p95; + } + + public void setP95(long p95) { + this.p95 = p95; + } + + public long getP99() { + return p99; + } + + public void setP99(long p99) { + this.p99 = p99; + } + + public long getP999() { + return p999; + } + + public void setP999(long p999) { + this.p999 = p999; + } + + public long getMax() { + return max; + } + + public void setMax(long max) { + this.max = max; + } } - public long getP50() { - return p50; + public GCStats() { } - public void setP50(long p50) { - this.p50 = p50; + public String getGcName() { + return gcName; } - public long getP95() { - return p95; + public void setGcName(String gcName) { + this.gcName = gcName; } - public void setP95(long p95) { - this.p95 = p95; + public long getCollectionCount() { + return collectionCount; } - public long getP99() { - return p99; + public void setCollectionCount(long collectionCount) { + this.collectionCount = collectionCount; } - public void setP99(long p99) { - this.p99 = p99; + public long getCollectionTime() { + return collectionTime; } - public long getP999() { - return p999; + public void setCollectionTime(long collectionTime) { + this.collectionTime = collectionTime; } - public void setP999(long p999) { - this.p999 = p999; + public long getLastPauseDuration() { + return lastPauseDuration; } - public long getMax() { - return max; + public void setLastPauseDuration(long lastPauseDuration) { + this.lastPauseDuration = lastPauseDuration; } - public void setMax(long max) { - this.max = max; + public List getRecentPauses() { + return recentPauses; } - } - - public GCStats() {} - - public String getGcName() { - return gcName; - } - - public void setGcName(String gcName) { - this.gcName = gcName; - } - - public long getCollectionCount() { - return collectionCount; - } - - public void setCollectionCount(long collectionCount) { - this.collectionCount = collectionCount; - } - - public long getCollectionTime() { - return collectionTime; - } - - public void setCollectionTime(long collectionTime) { - this.collectionTime = collectionTime; - } - - public long getLastPauseDuration() { - return lastPauseDuration; - } - - public void setLastPauseDuration(long lastPauseDuration) { - this.lastPauseDuration = lastPauseDuration; - } - public List getRecentPauses() { - return recentPauses; - } - - public void setRecentPauses(List recentPauses) { - this.recentPauses = recentPauses; - } + public void setRecentPauses(List recentPauses) { + this.recentPauses = recentPauses; + } - public PausePercentiles getPercentiles() { - return percentiles; - } + public PausePercentiles getPercentiles() { + return percentiles; + } - public void setPercentiles(PausePercentiles percentiles) { - this.percentiles = percentiles; - } + public void setPercentiles(PausePercentiles percentiles) { + this.percentiles = percentiles; + } - public long getTotalMemory() { - return totalMemory; - } + public long getTotalMemory() { + return totalMemory; + } - public void setTotalMemory(long totalMemory) { - this.totalMemory = totalMemory; - } + public void setTotalMemory(long totalMemory) { + this.totalMemory = totalMemory; + } - public long getUsedMemory() { - return usedMemory; - } + public long getUsedMemory() { + return usedMemory; + } - public void setUsedMemory(long usedMemory) { - this.usedMemory = usedMemory; - } + public void setUsedMemory(long usedMemory) { + this.usedMemory = usedMemory; + } - public long getFreeMemory() { - return freeMemory; - } + public long getFreeMemory() { + return freeMemory; + } - public void setFreeMemory(long freeMemory) { - this.freeMemory = freeMemory; - } + public void setFreeMemory(long freeMemory) { + this.freeMemory = freeMemory; + } - public Map getPhaseBreakdown() { - return phaseBreakdown; - } + public Map getPhaseBreakdown() { + return phaseBreakdown; + } - public void setPhaseBreakdown(Map phaseBreakdown) { - this.phaseBreakdown = phaseBreakdown; - } + public void setPhaseBreakdown(Map phaseBreakdown) { + this.phaseBreakdown = phaseBreakdown; + } } diff --git a/src/main/java/fish/payara/trader/gc/GCStatsService.java b/src/main/java/fish/payara/trader/gc/GCStatsService.java index 74064bc..e4a3368 100644 --- a/src/main/java/fish/payara/trader/gc/GCStatsService.java +++ b/src/main/java/fish/payara/trader/gc/GCStatsService.java @@ -20,288 +20,270 @@ @ApplicationScoped public class GCStatsService implements NotificationListener { - private static final Logger LOGGER = Logger.getLogger(GCStatsService.class.getName()); - private static final int MAX_PAUSE_HISTORY = 1000; - - private final Map> pauseHistory = new HashMap<>(); - private final Map> phaseHistory = new HashMap<>(); - - /** - * Breakdown of GC pause into constituent phases (e.g., Mark, Relocate, Evacuate). - * - *

Not all JVMs expose detailed phase information via GcInfo. When unavailable, the breakdown - * will contain only a "Total" phase with the full pause duration. This is a best-effort - * extraction that varies by GC implementation (G1, C4, ZGC, etc.). - */ - public static class GCPhaseBreakdown { - public final long totalDurationMs; - public final Map phaseDurationsMs; - public final long timestamp; - - public GCPhaseBreakdown( - long totalDurationMs, Map phaseDurationsMs, long timestamp) { - this.totalDurationMs = totalDurationMs; - this.phaseDurationsMs = Map.copyOf(phaseDurationsMs); // Immutable copy - this.timestamp = timestamp; + private static final Logger LOGGER = Logger.getLogger(GCStatsService.class.getName()); + private static final int MAX_PAUSE_HISTORY = 1000; + + private final Map> pauseHistory = new HashMap<>(); + private final Map> phaseHistory = new HashMap<>(); + + /** + * Breakdown of GC pause into constituent phases (e.g., Mark, Relocate, Evacuate). + * + *

+ * Not all JVMs expose detailed phase information via GcInfo. When unavailable, the breakdown will contain only a "Total" phase with the full pause + * duration. This is a best-effort extraction that varies by GC implementation (G1, C4, ZGC, etc.). + */ + public static class GCPhaseBreakdown { + public final long totalDurationMs; + public final Map phaseDurationsMs; + public final long timestamp; + + public GCPhaseBreakdown(long totalDurationMs, Map phaseDurationsMs, long timestamp) { + this.totalDurationMs = totalDurationMs; + this.phaseDurationsMs = Map.copyOf(phaseDurationsMs); // Immutable copy + this.timestamp = timestamp; + } } - } - - /** - * Statistics for a single GC phase across multiple collections. - * - *

Provides percentile distribution of phase durations to identify which phases dominate pause - * times. - */ - public static class PhaseStats { - public final String phaseName; - public final long count; - public final long p50Ms; - public final long p95Ms; - public final long p99Ms; - public final long maxMs; - - public PhaseStats( - String phaseName, long count, long p50Ms, long p95Ms, long p99Ms, long maxMs) { - this.phaseName = phaseName; - this.count = count; - this.p50Ms = p50Ms; - this.p95Ms = p95Ms; - this.p99Ms = p99Ms; - this.maxMs = maxMs; + + /** + * Statistics for a single GC phase across multiple collections. + * + *

+ * Provides percentile distribution of phase durations to identify which phases dominate pause times. + */ + public static class PhaseStats { + public final String phaseName; + public final long count; + public final long p50Ms; + public final long p95Ms; + public final long p99Ms; + public final long maxMs; + + public PhaseStats(String phaseName, long count, long p50Ms, long p95Ms, long p99Ms, long maxMs) { + this.phaseName = phaseName; + this.count = count; + this.p50Ms = p50Ms; + this.p95Ms = p95Ms; + this.p99Ms = p99Ms; + this.maxMs = maxMs; + } } - } - - @PostConstruct - public void init() { - LOGGER.info("Initializing GC Notification Listener..."); - List gcBeans = ManagementFactory.getGarbageCollectorMXBeans(); - for (GarbageCollectorMXBean gcBean : gcBeans) { - LOGGER.info("Registering listener for GC Bean: " + gcBean.getName()); - if (gcBean instanceof NotificationEmitter) { - ((NotificationEmitter) gcBean).addNotificationListener(this, null, null); - } + + @PostConstruct + public void init() { + LOGGER.info("Initializing GC Notification Listener..."); + List gcBeans = ManagementFactory.getGarbageCollectorMXBeans(); + for (GarbageCollectorMXBean gcBean : gcBeans) { + LOGGER.info("Registering listener for GC Bean: " + gcBean.getName()); + if (gcBean instanceof NotificationEmitter) { + ((NotificationEmitter) gcBean).addNotificationListener(this, null, null); + } + } } - } - - @Override - public void handleNotification(Notification notification, Object handback) { - if (notification - .getType() - .equals(GarbageCollectionNotificationInfo.GARBAGE_COLLECTION_NOTIFICATION)) { - GarbageCollectionNotificationInfo info = - GarbageCollectionNotificationInfo.from((CompositeData) notification.getUserData()); - - String gcName = info.getGcName(); - String gcAction = info.getGcAction(); - String gcCause = info.getGcCause(); - GcInfo gcInfo = info.getGcInfo(); - long duration = gcInfo.getDuration(); - - // FILTERING LOGIC: - // Azul C4 exposes "GPGC" (Concurrent Cycle) and "GPGC Pauses" (STW Pauses). - // We MUST ignore "GPGC" because it reports cycle time (hundreds of ms) which is NOT a pause. - if ("GPGC".equals(gcName)) { - return; - } - - // Also ignore other known concurrent cycle beans if they appear - if (gcName.contains("Cycles") && !gcName.contains("Pauses")) { - return; - } - - // Pause duration history (existing logic) - ConcurrentLinkedDeque history = - pauseHistory.computeIfAbsent(gcName, k -> new ConcurrentLinkedDeque<>()); - history.addLast(duration); - while (history.size() > MAX_PAUSE_HISTORY) { - history.removeFirst(); - } - - // Phase breakdown extraction (new logic) - Map phaseTimes = extractPhaseTimes(gcInfo, gcName, gcAction); - GCPhaseBreakdown breakdown = - new GCPhaseBreakdown(duration, phaseTimes, System.currentTimeMillis()); - - ConcurrentLinkedDeque phaseHist = - phaseHistory.computeIfAbsent(gcName, k -> new ConcurrentLinkedDeque<>()); - phaseHist.addLast(breakdown); - while (phaseHist.size() > MAX_PAUSE_HISTORY) { - phaseHist.removeFirst(); - } - - if (duration > 10) { - LOGGER.info( - String.format( - "GC Pause detected: %s | Action: %s | Cause: %s | Duration: %d ms", - gcName, gcAction, gcCause, duration)); - } + + @Override + public void handleNotification(Notification notification, Object handback) { + if (notification.getType().equals(GarbageCollectionNotificationInfo.GARBAGE_COLLECTION_NOTIFICATION)) { + GarbageCollectionNotificationInfo info = GarbageCollectionNotificationInfo.from((CompositeData) notification.getUserData()); + + String gcName = info.getGcName(); + String gcAction = info.getGcAction(); + String gcCause = info.getGcCause(); + GcInfo gcInfo = info.getGcInfo(); + long duration = gcInfo.getDuration(); + + // FILTERING LOGIC: + // Azul C4 exposes "GPGC" (Concurrent Cycle) and "GPGC Pauses" (STW Pauses). + // We MUST ignore "GPGC" because it reports cycle time (hundreds of ms) which is NOT a pause. + if ("GPGC".equals(gcName)) { + return; + } + + // Also ignore other known concurrent cycle beans if they appear + if (gcName.contains("Cycles") && !gcName.contains("Pauses")) { + return; + } + + // Pause duration history (existing logic) + ConcurrentLinkedDeque history = pauseHistory.computeIfAbsent(gcName, k -> new ConcurrentLinkedDeque<>()); + history.addLast(duration); + while (history.size() > MAX_PAUSE_HISTORY) { + history.removeFirst(); + } + + // Phase breakdown extraction (new logic) + Map phaseTimes = extractPhaseTimes(gcInfo, gcName, gcAction); + GCPhaseBreakdown breakdown = new GCPhaseBreakdown(duration, phaseTimes, System.currentTimeMillis()); + + ConcurrentLinkedDeque phaseHist = phaseHistory.computeIfAbsent(gcName, k -> new ConcurrentLinkedDeque<>()); + phaseHist.addLast(breakdown); + while (phaseHist.size() > MAX_PAUSE_HISTORY) { + phaseHist.removeFirst(); + } + + if (duration > 10) { + LOGGER.info(String.format("GC Pause detected: %s | Action: %s | Cause: %s | Duration: %d ms", gcName, gcAction, gcCause, duration)); + } + } } - } - - /** - * Extracts GC phase timings from GcInfo metadata. - * - *

This is JVM and collector-specific - detailed phase data may not be available on all - * platforms. The standard GcInfo API does not expose phase-level timing; vendors may provide this - * via custom MBeans or extended attributes. - * - *

For production-grade phase analysis, consider parsing GC logs with -Xlog:gc*=debug or using - * vendor-specific monitoring tools. - * - * @param gcInfo GC information from notification - * @param gcName Name of garbage collector (e.g., "G1 Young Generation", "GPGC Pauses") - * @param gcAction GC action string (e.g., "end of minor GC", "end of major GC") - * @return Map of phase names to durations in milliseconds. Contains at minimum a "Total" entry - * with the full pause duration. - */ - private Map extractPhaseTimes(GcInfo gcInfo, String gcName, String gcAction) { - Map phases = new HashMap<>(); - - try { - // Fallback: Always include total duration as baseline - phases.put("Total", gcInfo.getDuration()); - - // Vendor-specific extraction would go here - // GcInfo doesn't expose phases in the standard API - // - // For G1GC: Would need to parse GC logs or use JFR events - // For C4: Would need Azul-specific MBeans - // For ZGC/Shenandoah: Would need to parse logs or use vendor tools - // - // Current implementation provides "Total" phase as a foundation. - // Future enhancement: Add vendor-specific extraction logic. - - } catch (Exception e) { - LOGGER.fine("Could not extract GC phase breakdown: " + e.getMessage()); - phases.clear(); - phases.put("Total", gcInfo.getDuration()); + + /** + * Extracts GC phase timings from GcInfo metadata. + * + *

+ * This is JVM and collector-specific - detailed phase data may not be available on all platforms. The standard GcInfo API does not expose phase-level + * timing; vendors may provide this via custom MBeans or extended attributes. + * + *

+ * For production-grade phase analysis, consider parsing GC logs with -Xlog:gc*=debug or using vendor-specific monitoring tools. + * + * @param gcInfo + * GC information from notification + * @param gcName + * Name of garbage collector (e.g., "G1 Young Generation", "GPGC Pauses") + * @param gcAction + * GC action string (e.g., "end of minor GC", "end of major GC") + * @return Map of phase names to durations in milliseconds. Contains at minimum a "Total" entry with the full pause duration. + */ + private Map extractPhaseTimes(GcInfo gcInfo, String gcName, String gcAction) { + Map phases = new HashMap<>(); + + try { + // Fallback: Always include total duration as baseline + phases.put("Total", gcInfo.getDuration()); + + // Vendor-specific extraction would go here + // GcInfo doesn't expose phases in the standard API + // + // For G1GC: Would need to parse GC logs or use JFR events + // For C4: Would need Azul-specific MBeans + // For ZGC/Shenandoah: Would need to parse logs or use vendor tools + // + // Current implementation provides "Total" phase as a foundation. + // Future enhancement: Add vendor-specific extraction logic. + + } catch (Exception e) { + LOGGER.fine("Could not extract GC phase breakdown: " + e.getMessage()); + phases.clear(); + phases.put("Total", gcInfo.getDuration()); + } + + return phases; } - return phases; - } + public List collectGCStats() { + List gcBeans = ManagementFactory.getGarbageCollectorMXBeans(); + MemoryMXBean memoryBean = ManagementFactory.getMemoryMXBean(); + MemoryUsage heapUsage = memoryBean.getHeapMemoryUsage(); - public List collectGCStats() { - List gcBeans = ManagementFactory.getGarbageCollectorMXBeans(); - MemoryMXBean memoryBean = ManagementFactory.getMemoryMXBean(); - MemoryUsage heapUsage = memoryBean.getHeapMemoryUsage(); + List statsList = new ArrayList<>(); - List statsList = new ArrayList<>(); + for (GarbageCollectorMXBean gcBean : gcBeans) { + String gcName = gcBean.getName(); - for (GarbageCollectorMXBean gcBean : gcBeans) { - String gcName = gcBean.getName(); + // Exclude concurrent cycle collectors from the public stats to avoid confusion + if ("GPGC".equals(gcName) || (gcName.contains("Cycles") && !gcName.contains("Pauses"))) { + continue; + } - // Exclude concurrent cycle collectors from the public stats to avoid confusion - if ("GPGC".equals(gcName) || (gcName.contains("Cycles") && !gcName.contains("Pauses"))) { - continue; - } + GCStats stats = new GCStats(); + stats.setGcName(gcName); + stats.setCollectionCount(gcBean.getCollectionCount()); + stats.setCollectionTime(gcBean.getCollectionTime()); - GCStats stats = new GCStats(); - stats.setGcName(gcName); - stats.setCollectionCount(gcBean.getCollectionCount()); - stats.setCollectionTime(gcBean.getCollectionTime()); + ConcurrentLinkedDeque history = pauseHistory.get(gcName); + if (history != null && !history.isEmpty()) { + List pauses = new ArrayList<>(history); + stats.setLastPauseDuration(pauses.get(pauses.size() - 1)); - ConcurrentLinkedDeque history = pauseHistory.get(gcName); - if (history != null && !history.isEmpty()) { - List pauses = new ArrayList<>(history); - stats.setLastPauseDuration(pauses.get(pauses.size() - 1)); + stats.setRecentPauses(pauses.subList(Math.max(0, pauses.size() - 100), pauses.size())); - stats.setRecentPauses(pauses.subList(Math.max(0, pauses.size() - 100), pauses.size())); + List sortedPauses = pauses.stream().sorted().collect(Collectors.toList()); - List sortedPauses = pauses.stream().sorted().collect(Collectors.toList()); + stats.setPercentiles(calculatePercentiles(sortedPauses)); + } else { + stats.setLastPauseDuration(0); + stats.setRecentPauses(Collections.emptyList()); + stats.setPercentiles(new GCStats.PausePercentiles(0, 0, 0, 0, 0)); + } - stats.setPercentiles(calculatePercentiles(sortedPauses)); - } else { - stats.setLastPauseDuration(0); - stats.setRecentPauses(Collections.emptyList()); - stats.setPercentiles(new GCStats.PausePercentiles(0, 0, 0, 0, 0)); - } + stats.setTotalMemory(heapUsage.getMax()); + stats.setUsedMemory(heapUsage.getUsed()); + stats.setFreeMemory(heapUsage.getMax() - heapUsage.getUsed()); - stats.setTotalMemory(heapUsage.getMax()); - stats.setUsedMemory(heapUsage.getUsed()); - stats.setFreeMemory(heapUsage.getMax() - heapUsage.getUsed()); + // Add phase breakdown if available + ConcurrentLinkedDeque phaseHist = phaseHistory.get(gcName); + if (phaseHist != null && !phaseHist.isEmpty()) { + stats.setPhaseBreakdown(calculatePhaseStats(new ArrayList<>(phaseHist))); + } - // Add phase breakdown if available - ConcurrentLinkedDeque phaseHist = phaseHistory.get(gcName); - if (phaseHist != null && !phaseHist.isEmpty()) { - stats.setPhaseBreakdown(calculatePhaseStats(new ArrayList<>(phaseHist))); - } + statsList.add(stats); + } - statsList.add(stats); + return statsList; } - return statsList; - } + private GCStats.PausePercentiles calculatePercentiles(List sortedPauses) { + if (sortedPauses.isEmpty()) { + return new GCStats.PausePercentiles(0, 0, 0, 0, 0); + } - private GCStats.PausePercentiles calculatePercentiles(List sortedPauses) { - if (sortedPauses.isEmpty()) { - return new GCStats.PausePercentiles(0, 0, 0, 0, 0); + int size = sortedPauses.size(); + return new GCStats.PausePercentiles(percentile(sortedPauses, 0.50), percentile(sortedPauses, 0.95), percentile(sortedPauses, 0.99), + percentile(sortedPauses, 0.999), sortedPauses.get(size - 1)); } - int size = sortedPauses.size(); - return new GCStats.PausePercentiles( - percentile(sortedPauses, 0.50), - percentile(sortedPauses, 0.95), - percentile(sortedPauses, 0.99), - percentile(sortedPauses, 0.999), - sortedPauses.get(size - 1)); - } - - private long percentile(List sortedValues, double percentile) { - int index = (int) Math.ceil(percentile * sortedValues.size()) - 1; - index = Math.max(0, Math.min(index, sortedValues.size() - 1)); - return sortedValues.get(index); - } - - /** - * Calculates per-phase statistics from breakdown history. - * - *

Groups phase durations by phase name and computes percentiles for each phase independently. - * This allows identifying which phases contribute most to pause times (e.g., "Mark" vs "Evacuate" - * in G1GC). - * - * @param breakdowns List of GC phase breakdowns from notification history - * @return Map of phase names to statistics (count, percentiles) - */ - private Map calculatePhaseStats(List breakdowns) { - Map phaseStatsMap = new HashMap<>(); - - if (breakdowns.isEmpty()) { - return phaseStatsMap; + private long percentile(List sortedValues, double percentile) { + int index = (int) Math.ceil(percentile * sortedValues.size()) - 1; + index = Math.max(0, Math.min(index, sortedValues.size() - 1)); + return sortedValues.get(index); } - // Group durations by phase name - Map> phaseGroups = new HashMap<>(); - for (GCPhaseBreakdown breakdown : breakdowns) { - for (Map.Entry entry : breakdown.phaseDurationsMs.entrySet()) { - phaseGroups.computeIfAbsent(entry.getKey(), k -> new ArrayList<>()).add(entry.getValue()); - } + /** + * Calculates per-phase statistics from breakdown history. + * + *

+ * Groups phase durations by phase name and computes percentiles for each phase independently. This allows identifying which phases contribute most to pause + * times (e.g., "Mark" vs "Evacuate" in G1GC). + * + * @param breakdowns + * List of GC phase breakdowns from notification history + * @return Map of phase names to statistics (count, percentiles) + */ + private Map calculatePhaseStats(List breakdowns) { + Map phaseStatsMap = new HashMap<>(); + + if (breakdowns.isEmpty()) { + return phaseStatsMap; + } + + // Group durations by phase name + Map> phaseGroups = new HashMap<>(); + for (GCPhaseBreakdown breakdown : breakdowns) { + for (Map.Entry entry : breakdown.phaseDurationsMs.entrySet()) { + phaseGroups.computeIfAbsent(entry.getKey(), k -> new ArrayList<>()).add(entry.getValue()); + } + } + + // Calculate percentiles for each phase + for (Map.Entry> entry : phaseGroups.entrySet()) { + String phaseName = entry.getKey(); + List durations = entry.getValue(); + Collections.sort(durations); + + PhaseStats stats = new PhaseStats(phaseName, durations.size(), percentile(durations, 0.50), percentile(durations, 0.95), + percentile(durations, 0.99), durations.get(durations.size() - 1) // max + ); + + phaseStatsMap.put(phaseName, stats); + } + + return phaseStatsMap; } - // Calculate percentiles for each phase - for (Map.Entry> entry : phaseGroups.entrySet()) { - String phaseName = entry.getKey(); - List durations = entry.getValue(); - Collections.sort(durations); - - PhaseStats stats = - new PhaseStats( - phaseName, - durations.size(), - percentile(durations, 0.50), - percentile(durations, 0.95), - percentile(durations, 0.99), - durations.get(durations.size() - 1) // max - ); - - phaseStatsMap.put(phaseName, stats); + public void resetStats() { + pauseHistory.clear(); + phaseHistory.clear(); + LOGGER.info("GC statistics reset"); } - - return phaseStatsMap; - } - - public void resetStats() { - pauseHistory.clear(); - phaseHistory.clear(); - LOGGER.info("GC statistics reset"); - } } diff --git a/src/main/java/fish/payara/trader/monitoring/GCPauseMonitor.java b/src/main/java/fish/payara/trader/monitoring/GCPauseMonitor.java index c45adcb..7065b73 100644 --- a/src/main/java/fish/payara/trader/monitoring/GCPauseMonitor.java +++ b/src/main/java/fish/payara/trader/monitoring/GCPauseMonitor.java @@ -22,208 +22,183 @@ @ApplicationScoped public class GCPauseMonitor implements NotificationListener { - private static final Logger LOGGER = Logger.getLogger(GCPauseMonitor.class.getName()); + private static final Logger LOGGER = Logger.getLogger(GCPauseMonitor.class.getName()); - private static final int MAX_PAUSE_HISTORY = 500; + private static final int MAX_PAUSE_HISTORY = 500; - private final ConcurrentLinkedDeque pauseHistory = new ConcurrentLinkedDeque<>(); + private final ConcurrentLinkedDeque pauseHistory = new ConcurrentLinkedDeque<>(); - private final AtomicLong totalPauseCount = new AtomicLong(0); - private final AtomicLong totalPauseTimeMs = new AtomicLong(0); - private volatile long maxPauseMs = 0; + private final AtomicLong totalPauseCount = new AtomicLong(0); + private final AtomicLong totalPauseTimeMs = new AtomicLong(0); + private volatile long maxPauseMs = 0; - private final AtomicLong violationsOver10ms = new AtomicLong(0); - private final AtomicLong violationsOver50ms = new AtomicLong(0); - private final AtomicLong violationsOver100ms = new AtomicLong(0); + private final AtomicLong violationsOver10ms = new AtomicLong(0); + private final AtomicLong violationsOver50ms = new AtomicLong(0); + private final AtomicLong violationsOver100ms = new AtomicLong(0); - private final List emitters = new ArrayList<>(); + private final List emitters = new ArrayList<>(); - @PostConstruct - public void init() { - LOGGER.info("Initializing GC Pause Monitor with JMX notifications"); + @PostConstruct + public void init() { + LOGGER.info("Initializing GC Pause Monitor with JMX notifications"); - List gcBeans = ManagementFactory.getGarbageCollectorMXBeans(); + List gcBeans = ManagementFactory.getGarbageCollectorMXBeans(); - for (GarbageCollectorMXBean gcBean : gcBeans) { - if (gcBean instanceof NotificationEmitter) { - NotificationEmitter emitter = (NotificationEmitter) gcBean; - emitter.addNotificationListener(this, null, null); - emitters.add(emitter); - LOGGER.info("Registered GC notification listener for: " + gcBean.getName()); - } - } + for (GarbageCollectorMXBean gcBean : gcBeans) { + if (gcBean instanceof NotificationEmitter) { + NotificationEmitter emitter = (NotificationEmitter) gcBean; + emitter.addNotificationListener(this, null, null); + emitters.add(emitter); + LOGGER.info("Registered GC notification listener for: " + gcBean.getName()); + } + } - if (emitters.isEmpty()) { - LOGGER.warning("No GC notification emitters found - pause monitoring may be limited"); - } - } - - @PreDestroy - public void cleanup() { - for (NotificationEmitter emitter : emitters) { - try { - emitter.removeNotificationListener(this); - } catch (Exception e) { - LOGGER.log(Level.WARNING, "Failed to remove GC notification listener", e); - } + if (emitters.isEmpty()) { + LOGGER.warning("No GC notification emitters found - pause monitoring may be limited"); + } } - emitters.clear(); - } - - @Override - public void handleNotification(Notification notification, Object handback) { - if (!notification - .getType() - .equals(GarbageCollectionNotificationInfo.GARBAGE_COLLECTION_NOTIFICATION)) { - return; + + @PreDestroy + public void cleanup() { + for (NotificationEmitter emitter : emitters) { + try { + emitter.removeNotificationListener(this); + } catch (Exception e) { + LOGGER.log(Level.WARNING, "Failed to remove GC notification listener", e); + } + } + emitters.clear(); } - try { - CompositeData cd = (CompositeData) notification.getUserData(); - GarbageCollectionNotificationInfo info = GarbageCollectionNotificationInfo.from(cd); - String gcName = info.getGcName(); + @Override + public void handleNotification(Notification notification, Object handback) { + if (!notification.getType().equals(GarbageCollectionNotificationInfo.GARBAGE_COLLECTION_NOTIFICATION)) { + return; + } - if ("GPGC".equals(gcName) || (gcName.contains("Cycles") && !gcName.contains("Pauses"))) { - return; - } + try { + CompositeData cd = (CompositeData) notification.getUserData(); + GarbageCollectionNotificationInfo info = GarbageCollectionNotificationInfo.from(cd); + String gcName = info.getGcName(); - GcInfo gcInfo = info.getGcInfo(); - long pauseMs = gcInfo.getDuration(); + if ("GPGC".equals(gcName) || (gcName.contains("Cycles") && !gcName.contains("Pauses"))) { + return; + } - recordPause(pauseMs, gcName, info.getGcAction()); + GcInfo gcInfo = info.getGcInfo(); + long pauseMs = gcInfo.getDuration(); - } catch (Exception e) { - LOGGER.log(Level.WARNING, "Error processing GC notification", e); - } - } + recordPause(pauseMs, gcName, info.getGcAction()); - private void recordPause(long pauseMs, String gcName, String gcAction) { - pauseHistory.addLast(pauseMs); - if (pauseHistory.size() > MAX_PAUSE_HISTORY) { - pauseHistory.removeFirst(); + } catch (Exception e) { + LOGGER.log(Level.WARNING, "Error processing GC notification", e); + } } - totalPauseCount.incrementAndGet(); - totalPauseTimeMs.addAndGet(pauseMs); + private void recordPause(long pauseMs, String gcName, String gcAction) { + pauseHistory.addLast(pauseMs); + if (pauseHistory.size() > MAX_PAUSE_HISTORY) { + pauseHistory.removeFirst(); + } + + totalPauseCount.incrementAndGet(); + totalPauseTimeMs.addAndGet(pauseMs); - if (pauseMs > maxPauseMs) { - synchronized (this) { if (pauseMs > maxPauseMs) { - maxPauseMs = pauseMs; + synchronized (this) { + if (pauseMs > maxPauseMs) { + maxPauseMs = pauseMs; + } + } } - } - } - if (pauseMs > 100) { - violationsOver100ms.incrementAndGet(); - violationsOver50ms.incrementAndGet(); - violationsOver10ms.incrementAndGet(); - } else if (pauseMs > 50) { - violationsOver50ms.incrementAndGet(); - violationsOver10ms.incrementAndGet(); - } else if (pauseMs > 10) { - violationsOver10ms.incrementAndGet(); + if (pauseMs > 100) { + violationsOver100ms.incrementAndGet(); + violationsOver50ms.incrementAndGet(); + violationsOver10ms.incrementAndGet(); + } else if (pauseMs > 50) { + violationsOver50ms.incrementAndGet(); + violationsOver10ms.incrementAndGet(); + } else if (pauseMs > 10) { + violationsOver10ms.incrementAndGet(); + } + + if (pauseMs > 100) { + LOGGER.warning(String.format("Large GC pause detected: %d ms [%s - %s]", pauseMs, gcName, gcAction)); + } else if (pauseMs > 50) { + LOGGER.info(String.format("Notable GC pause: %d ms [%s - %s]", pauseMs, gcName, gcAction)); + } } - if (pauseMs > 100) { - LOGGER.warning( - String.format("Large GC pause detected: %d ms [%s - %s]", pauseMs, gcName, gcAction)); - } else if (pauseMs > 50) { - LOGGER.info(String.format("Notable GC pause: %d ms [%s - %s]", pauseMs, gcName, gcAction)); + public GCPauseStats getStats() { + List pauses = new ArrayList<>(pauseHistory); + + if (pauses.isEmpty()) { + return new GCPauseStats(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0); + } + + Collections.sort(pauses); + + long p50 = percentile(pauses, 0.50); + long p95 = percentile(pauses, 0.95); + long p99 = percentile(pauses, 0.99); + long p999 = percentile(pauses, 0.999); + long max = pauses.get(pauses.size() - 1); + + long count = totalPauseCount.get(); + long totalTime = totalPauseTimeMs.get(); + double avgPause = count > 0 ? (double) totalTime / count : 0; + + return new GCPauseStats(count, totalTime, avgPause, p50, p95, p99, p999, maxPauseMs, // All-time max + violationsOver10ms.get(), violationsOver50ms.get(), violationsOver100ms.get(), pauses.size() // Sample size for percentiles + ); } - } - public GCPauseStats getStats() { - List pauses = new ArrayList<>(pauseHistory); + private long percentile(List sortedValues, double percentile) { + int index = (int) Math.ceil(percentile * sortedValues.size()) - 1; + index = Math.max(0, Math.min(index, sortedValues.size() - 1)); + return sortedValues.get(index); + } - if (pauses.isEmpty()) { - return new GCPauseStats(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0); + public void reset() { + pauseHistory.clear(); + totalPauseCount.set(0); + totalPauseTimeMs.set(0); + maxPauseMs = 0; + violationsOver10ms.set(0); + violationsOver50ms.set(0); + violationsOver100ms.set(0); + LOGGER.info("GC pause statistics reset"); } - Collections.sort(pauses); - - long p50 = percentile(pauses, 0.50); - long p95 = percentile(pauses, 0.95); - long p99 = percentile(pauses, 0.99); - long p999 = percentile(pauses, 0.999); - long max = pauses.get(pauses.size() - 1); - - long count = totalPauseCount.get(); - long totalTime = totalPauseTimeMs.get(); - double avgPause = count > 0 ? (double) totalTime / count : 0; - - return new GCPauseStats( - count, - totalTime, - avgPause, - p50, - p95, - p99, - p999, - maxPauseMs, // All-time max - violationsOver10ms.get(), - violationsOver50ms.get(), - violationsOver100ms.get(), - pauses.size() // Sample size for percentiles - ); - } - - private long percentile(List sortedValues, double percentile) { - int index = (int) Math.ceil(percentile * sortedValues.size()) - 1; - index = Math.max(0, Math.min(index, sortedValues.size() - 1)); - return sortedValues.get(index); - } - - public void reset() { - pauseHistory.clear(); - totalPauseCount.set(0); - totalPauseTimeMs.set(0); - maxPauseMs = 0; - violationsOver10ms.set(0); - violationsOver50ms.set(0); - violationsOver100ms.set(0); - LOGGER.info("GC pause statistics reset"); - } - - public static class GCPauseStats { - public final long totalPauseCount; - public final long totalPauseTimeMs; - public final double avgPauseMs; - public final long p50Ms; - public final long p95Ms; - public final long p99Ms; - public final long p999Ms; - public final long maxMs; - public final long violationsOver10ms; - public final long violationsOver50ms; - public final long violationsOver100ms; - public final int sampleSize; - - public GCPauseStats( - long totalPauseCount, - long totalPauseTimeMs, - double avgPauseMs, - long p50Ms, - long p95Ms, - long p99Ms, - long p999Ms, - long maxMs, - long violationsOver10ms, - long violationsOver50ms, - long violationsOver100ms, - int sampleSize) { - this.totalPauseCount = totalPauseCount; - this.totalPauseTimeMs = totalPauseTimeMs; - this.avgPauseMs = avgPauseMs; - this.p50Ms = p50Ms; - this.p95Ms = p95Ms; - this.p99Ms = p99Ms; - this.p999Ms = p999Ms; - this.maxMs = maxMs; - this.violationsOver10ms = violationsOver10ms; - this.violationsOver50ms = violationsOver50ms; - this.violationsOver100ms = violationsOver100ms; - this.sampleSize = sampleSize; + public static class GCPauseStats { + public final long totalPauseCount; + public final long totalPauseTimeMs; + public final double avgPauseMs; + public final long p50Ms; + public final long p95Ms; + public final long p99Ms; + public final long p999Ms; + public final long maxMs; + public final long violationsOver10ms; + public final long violationsOver50ms; + public final long violationsOver100ms; + public final int sampleSize; + + public GCPauseStats(long totalPauseCount, long totalPauseTimeMs, double avgPauseMs, long p50Ms, long p95Ms, long p99Ms, long p999Ms, long maxMs, + long violationsOver10ms, long violationsOver50ms, long violationsOver100ms, int sampleSize) { + this.totalPauseCount = totalPauseCount; + this.totalPauseTimeMs = totalPauseTimeMs; + this.avgPauseMs = avgPauseMs; + this.p50Ms = p50Ms; + this.p95Ms = p95Ms; + this.p99Ms = p99Ms; + this.p999Ms = p999Ms; + this.maxMs = maxMs; + this.violationsOver10ms = violationsOver10ms; + this.violationsOver50ms = violationsOver50ms; + this.violationsOver100ms = violationsOver100ms; + this.sampleSize = sampleSize; + } } - } } diff --git a/src/main/java/fish/payara/trader/monitoring/SLAMonitorService.java b/src/main/java/fish/payara/trader/monitoring/SLAMonitorService.java index 09518d2..e7767cc 100644 --- a/src/main/java/fish/payara/trader/monitoring/SLAMonitorService.java +++ b/src/main/java/fish/payara/trader/monitoring/SLAMonitorService.java @@ -8,87 +8,77 @@ @ApplicationScoped public class SLAMonitorService { - private static final Logger LOGGER = Logger.getLogger(SLAMonitorService.class.getName()); + private static final Logger LOGGER = Logger.getLogger(SLAMonitorService.class.getName()); - private static final long SLA_10MS = 10; - private static final long SLA_50MS = 50; - private static final long SLA_100MS = 100; + private static final long SLA_10MS = 10; + private static final long SLA_50MS = 50; + private static final long SLA_100MS = 100; - private final AtomicLong violationsOver10ms = new AtomicLong(0); - private final AtomicLong violationsOver50ms = new AtomicLong(0); - private final AtomicLong violationsOver100ms = new AtomicLong(0); - private final AtomicLong totalOperations = new AtomicLong(0); + private final AtomicLong violationsOver10ms = new AtomicLong(0); + private final AtomicLong violationsOver50ms = new AtomicLong(0); + private final AtomicLong violationsOver100ms = new AtomicLong(0); + private final AtomicLong totalOperations = new AtomicLong(0); - private final ConcurrentHashMap violationsByMinute = new ConcurrentHashMap<>(); + private final ConcurrentHashMap violationsByMinute = new ConcurrentHashMap<>(); - public void recordOperation(long latencyMs) { - totalOperations.incrementAndGet(); + public void recordOperation(long latencyMs) { + totalOperations.incrementAndGet(); - if (latencyMs > SLA_100MS) { - violationsOver100ms.incrementAndGet(); - violationsOver50ms.incrementAndGet(); - violationsOver10ms.incrementAndGet(); - recordViolation(); - } else if (latencyMs > SLA_50MS) { - violationsOver50ms.incrementAndGet(); - violationsOver10ms.incrementAndGet(); - recordViolation(); - } else if (latencyMs > SLA_10MS) { - violationsOver10ms.incrementAndGet(); - recordViolation(); + if (latencyMs > SLA_100MS) { + violationsOver100ms.incrementAndGet(); + violationsOver50ms.incrementAndGet(); + violationsOver10ms.incrementAndGet(); + recordViolation(); + } else if (latencyMs > SLA_50MS) { + violationsOver50ms.incrementAndGet(); + violationsOver10ms.incrementAndGet(); + recordViolation(); + } else if (latencyMs > SLA_10MS) { + violationsOver10ms.incrementAndGet(); + recordViolation(); + } } - } - private void recordViolation() { - long currentMinute = System.currentTimeMillis() / 60000; - violationsByMinute.merge(currentMinute, 1L, Long::sum); + private void recordViolation() { + long currentMinute = System.currentTimeMillis() / 60000; + violationsByMinute.merge(currentMinute, 1L, Long::sum); - long fiveMinutesAgo = currentMinute - 5; - violationsByMinute.keySet().removeIf(minute -> minute < fiveMinutesAgo); - } + long fiveMinutesAgo = currentMinute - 5; + violationsByMinute.keySet().removeIf(minute -> minute < fiveMinutesAgo); + } - public SLAStats getStats() { - long total = totalOperations.get(); + public SLAStats getStats() { + long total = totalOperations.get(); - return new SLAStats( - total, - violationsOver10ms.get(), - violationsOver50ms.get(), - violationsOver100ms.get(), - total > 0 ? (double) violationsOver10ms.get() / total * 100 : 0, - violationsByMinute.values().stream().mapToLong(Long::longValue).sum()); - } + return new SLAStats(total, violationsOver10ms.get(), violationsOver50ms.get(), violationsOver100ms.get(), + total > 0 ? (double) violationsOver10ms.get() / total * 100 : 0, violationsByMinute.values().stream().mapToLong(Long::longValue).sum()); + } - public void reset() { - violationsOver10ms.set(0); - violationsOver50ms.set(0); - violationsOver100ms.set(0); - totalOperations.set(0); - violationsByMinute.clear(); - LOGGER.info("SLA statistics reset"); - } + public void reset() { + violationsOver10ms.set(0); + violationsOver50ms.set(0); + violationsOver100ms.set(0); + totalOperations.set(0); + violationsByMinute.clear(); + LOGGER.info("SLA statistics reset"); + } - public static class SLAStats { - public final long totalOperations; - public final long violationsOver10ms; - public final long violationsOver50ms; - public final long violationsOver100ms; - public final double violationRate; - public final long recentViolations; // Last 5 minutes + public static class SLAStats { + public final long totalOperations; + public final long violationsOver10ms; + public final long violationsOver50ms; + public final long violationsOver100ms; + public final double violationRate; + public final long recentViolations; // Last 5 minutes - public SLAStats( - long totalOperations, - long violationsOver10ms, - long violationsOver50ms, - long violationsOver100ms, - double violationRate, - long recentViolations) { - this.totalOperations = totalOperations; - this.violationsOver10ms = violationsOver10ms; - this.violationsOver50ms = violationsOver50ms; - this.violationsOver100ms = violationsOver100ms; - this.violationRate = violationRate; - this.recentViolations = recentViolations; + public SLAStats(long totalOperations, long violationsOver10ms, long violationsOver50ms, long violationsOver100ms, double violationRate, + long recentViolations) { + this.totalOperations = totalOperations; + this.violationsOver10ms = violationsOver10ms; + this.violationsOver50ms = violationsOver50ms; + this.violationsOver100ms = violationsOver100ms; + this.violationRate = violationRate; + this.recentViolations = recentViolations; + } } - } } diff --git a/src/main/java/fish/payara/trader/pressure/AllocationMode.java b/src/main/java/fish/payara/trader/pressure/AllocationMode.java index a9eb0e0..62f4a7c 100644 --- a/src/main/java/fish/payara/trader/pressure/AllocationMode.java +++ b/src/main/java/fish/payara/trader/pressure/AllocationMode.java @@ -1,79 +1,64 @@ package fish.payara.trader.pressure; public enum AllocationMode { - OFF(0, 0, 0, ScenarioType.NONE, "No allocation"), + OFF(0, 0, 0, ScenarioType.NONE, "No allocation"), - STEADY_LOAD( - 200, // MB/sec allocation rate - 512, // MB live set size - 0, // No growth - ScenarioType.STEADY, - "Steady 200 MB/sec allocation, 512 MB live set - Tests baseline GC behavior"), + STEADY_LOAD(200, // MB/sec allocation rate + 512, // MB live set size + 0, // No growth + ScenarioType.STEADY, "Steady 200 MB/sec allocation, 512 MB live set - Tests baseline GC behavior"), - GROWING_HEAP( - 150, // MB/sec allocation rate - 2048, // MB target live set - 60, // seconds to reach target - ScenarioType.GROWING, - "Growing live set 100 MB -> 2 GB over 60s - Tests mixed collection scaling"), + GROWING_HEAP(150, // MB/sec allocation rate + 2048, // MB target live set + 60, // seconds to reach target + ScenarioType.GROWING, "Growing live set 100 MB -> 2 GB over 60s - Tests mixed collection scaling"), - PROMOTION_STORM( - 300, // MB/sec allocation rate - 1024, // MB live set - 0, // No growth - ScenarioType.PROMOTION, - "High promotion rate (50% survival) - Tests old gen collection efficiency"), + PROMOTION_STORM(300, // MB/sec allocation rate + 1024, // MB live set + 0, // No growth + ScenarioType.PROMOTION, "High promotion rate (50% survival) - Tests old gen collection efficiency"), - FRAGMENTATION( - 200, // MB/sec allocation rate - 1024, // MB live set - 0, // No growth - ScenarioType.FRAGMENTATION, - "Small objects, random lifetimes - Tests compaction behavior"), + FRAGMENTATION(200, // MB/sec allocation rate + 1024, // MB live set + 0, // No growth + ScenarioType.FRAGMENTATION, "Small objects, random lifetimes - Tests compaction behavior"), - CROSS_GEN_REFS( - 150, // MB/sec allocation rate - 800, // MB live set in old gen - 0, // No growth - ScenarioType.CROSS_REF, - "Many old->young references - Tests remembered set overhead"); + CROSS_GEN_REFS(150, // MB/sec allocation rate + 800, // MB live set in old gen + 0, // No growth + ScenarioType.CROSS_REF, "Many old->young references - Tests remembered set overhead"); - private final int allocationRateMBPerSec; - private final int liveSetSizeMB; - private final int growthDurationSeconds; - private final ScenarioType scenarioType; - private final String description; + private final int allocationRateMBPerSec; + private final int liveSetSizeMB; + private final int growthDurationSeconds; + private final ScenarioType scenarioType; + private final String description; - AllocationMode( - int allocationRateMBPerSec, - int liveSetSizeMB, - int growthDurationSeconds, - ScenarioType scenarioType, - String description) { - this.allocationRateMBPerSec = allocationRateMBPerSec; - this.liveSetSizeMB = liveSetSizeMB; - this.growthDurationSeconds = growthDurationSeconds; - this.scenarioType = scenarioType; - this.description = description; - } + AllocationMode(int allocationRateMBPerSec, int liveSetSizeMB, int growthDurationSeconds, ScenarioType scenarioType, String description) { + this.allocationRateMBPerSec = allocationRateMBPerSec; + this.liveSetSizeMB = liveSetSizeMB; + this.growthDurationSeconds = growthDurationSeconds; + this.scenarioType = scenarioType; + this.description = description; + } - public int getAllocationRateMBPerSec() { - return allocationRateMBPerSec; - } + public int getAllocationRateMBPerSec() { + return allocationRateMBPerSec; + } - public int getLiveSetSizeMB() { - return liveSetSizeMB; - } + public int getLiveSetSizeMB() { + return liveSetSizeMB; + } - public int getGrowthDurationSeconds() { - return growthDurationSeconds; - } + public int getGrowthDurationSeconds() { + return growthDurationSeconds; + } - public ScenarioType getScenarioType() { - return scenarioType; - } + public ScenarioType getScenarioType() { + return scenarioType; + } - public String getDescription() { - return description; - } + public String getDescription() { + return description; + } } diff --git a/src/main/java/fish/payara/trader/pressure/MemoryPressureService.java b/src/main/java/fish/payara/trader/pressure/MemoryPressureService.java index dad90e6..5b27c10 100644 --- a/src/main/java/fish/payara/trader/pressure/MemoryPressureService.java +++ b/src/main/java/fish/payara/trader/pressure/MemoryPressureService.java @@ -16,431 +16,413 @@ import java.util.logging.Logger; /** - * Service to generate controlled memory pressure for GC stress testing. Updated to use - * scenario-based testing targeting G1 vs C4 differences. + * Service to generate controlled memory pressure for GC stress testing. Updated to use scenario-based testing targeting G1 vs C4 differences. */ @ApplicationScoped public class MemoryPressureService { - private static final Logger LOGGER = Logger.getLogger(MemoryPressureService.class.getName()); + private static final Logger LOGGER = Logger.getLogger(MemoryPressureService.class.getName()); - private volatile AllocationMode currentMode = AllocationMode.OFF; - private volatile boolean running = false; - private Future pressureTask; + private volatile AllocationMode currentMode = AllocationMode.OFF; + private volatile boolean running = false; + private Future pressureTask; - private final AtomicLong totalBytesAllocated = new AtomicLong(0); - private long lastStatsTime = System.currentTimeMillis(); + private final AtomicLong totalBytesAllocated = new AtomicLong(0); + private long lastStatsTime = System.currentTimeMillis(); - // Live set management - private final Deque liveSet = new LinkedList<>(); - private final AtomicLong liveSetBytesAllocated = new AtomicLong(0); + // Live set management + private final Deque liveSet = new LinkedList<>(); + private final AtomicLong liveSetBytesAllocated = new AtomicLong(0); - // For Promotion Storm (thread-safe as multiple threads add to it) - private final Deque promotableObjects = new ConcurrentLinkedDeque<>(); + // For Promotion Storm (thread-safe as multiple threads add to it) + private final Deque promotableObjects = new ConcurrentLinkedDeque<>(); - // For Cross-Gen Refs scenario - holders in old gen that reference young objects - private final Deque crossRefHolders = new LinkedList<>(); - private final AtomicLong crossRefBytesAllocated = new AtomicLong(0); + // For Cross-Gen Refs scenario - holders in old gen that reference young objects + private final Deque crossRefHolders = new LinkedList<>(); + private final AtomicLong crossRefBytesAllocated = new AtomicLong(0); - // Scenario state - private final AtomicLong scenarioStartTime = new AtomicLong(0); + // Scenario state + private final AtomicLong scenarioStartTime = new AtomicLong(0); - /** - * Holder object that lives in old generation and holds a reference to a young object. Every - * update to youngRef triggers G1's write barrier and remembered set maintenance. - */ - private static class RefHolder { - volatile Object youngRef; // Reference to young gen object - updated frequently - final byte[] padding; // Padding to make holder substantial (~1MB each) + /** + * Holder object that lives in old generation and holds a reference to a young object. Every update to youngRef triggers G1's write barrier and remembered + * set maintenance. + */ + private static class RefHolder { + volatile Object youngRef; // Reference to young gen object - updated frequently + final byte[] padding; // Padding to make holder substantial (~1MB each) - RefHolder(int paddingSize) { - this.padding = new byte[paddingSize]; - ThreadLocalRandom.current().nextBytes(this.padding); + RefHolder(int paddingSize) { + this.padding = new byte[paddingSize]; + ThreadLocalRandom.current().nextBytes(this.padding); + } } - } - @Inject @VirtualThreadExecutor private ManagedExecutorService executorService; + @Inject + @VirtualThreadExecutor + private ManagedExecutorService executorService; - @PostConstruct - public void init() { - LOGGER.info("MemoryPressureService initialized"); - } - - public synchronized void setAllocationMode(AllocationMode mode) { - if (mode == currentMode) { - return; + @PostConstruct + public void init() { + LOGGER.info("MemoryPressureService initialized"); } - LOGGER.info("Changing allocation mode from " + currentMode + " to " + mode); - currentMode = mode; - - if (mode == AllocationMode.OFF) { - stopPressure(); - } else { - // clear previous state - liveSet.clear(); - liveSetBytesAllocated.set(0); - promotableObjects.clear(); - crossRefHolders.clear(); - crossRefBytesAllocated.set(0); - scenarioStartTime.set(System.currentTimeMillis()); - - startPressure(); + public synchronized void setAllocationMode(AllocationMode mode) { + if (mode == currentMode) { + return; + } + + LOGGER.info("Changing allocation mode from " + currentMode + " to " + mode); + currentMode = mode; + + if (mode == AllocationMode.OFF) { + stopPressure(); + } else { + // clear previous state + liveSet.clear(); + liveSetBytesAllocated.set(0); + promotableObjects.clear(); + crossRefHolders.clear(); + crossRefBytesAllocated.set(0); + scenarioStartTime.set(System.currentTimeMillis()); + + startPressure(); + } } - } - private synchronized void startPressure() { - if (running) { - return; - } + private synchronized void startPressure() { + if (running) { + return; + } - running = true; - totalBytesAllocated.set(0); - lastStatsTime = System.currentTimeMillis(); + running = true; + totalBytesAllocated.set(0); + lastStatsTime = System.currentTimeMillis(); - pressureTask = - executorService.submit( - () -> { - LOGGER.info("Memory pressure generator started with mode: " + currentMode); + pressureTask = executorService.submit(() -> { + LOGGER.info("Memory pressure generator started with mode: " + currentMode); - while (running) { + while (running) { try { - long loopStartTime = System.currentTimeMillis(); - AllocationMode mode = currentMode; - if (mode == AllocationMode.OFF) { - break; - } + long loopStartTime = System.currentTimeMillis(); + AllocationMode mode = currentMode; + if (mode == AllocationMode.OFF) { + break; + } - generateGarbage(mode); + generateGarbage(mode); - long executionTime = System.currentTimeMillis() - loopStartTime; - // Target 10 iterations per second (100ms per iteration) - long sleepTime = 100 - executionTime; + long executionTime = System.currentTimeMillis() - loopStartTime; + // Target 10 iterations per second (100ms per iteration) + long sleepTime = 100 - executionTime; - if (sleepTime > 0) { - Thread.sleep(sleepTime); - } + if (sleepTime > 0) { + Thread.sleep(sleepTime); + } - logStats(); + logStats(); } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - break; + Thread.currentThread().interrupt(); + break; } catch (Exception e) { - LOGGER.warning("Error in memory pressure generator: " + e.getMessage()); + LOGGER.warning("Error in memory pressure generator: " + e.getMessage()); } - } - - LOGGER.info("Memory pressure generator stopped"); - // Cleanup - liveSet.clear(); - liveSetBytesAllocated.set(0); - promotableObjects.clear(); - crossRefHolders.clear(); - crossRefBytesAllocated.set(0); - }); - } - - private synchronized void stopPressure() { - running = false; - if (pressureTask != null && !pressureTask.isDone()) { - pressureTask.cancel(true); - pressureTask = null; + } + + LOGGER.info("Memory pressure generator stopped"); + // Cleanup + liveSet.clear(); + liveSetBytesAllocated.set(0); + promotableObjects.clear(); + crossRefHolders.clear(); + crossRefBytesAllocated.set(0); + }); } - } - - private void generateGarbage(AllocationMode mode) { - switch (mode.getScenarioType()) { - case STEADY: - executeSteadyLoadScenario(mode); - break; - case GROWING: - executeGrowingHeapScenario(mode); - break; - case PROMOTION: - executePromotionStormScenario(mode); - break; - case FRAGMENTATION: - executeFragmentationScenario(mode); - break; - case CROSS_REF: - executeCrossRefsScenario(mode); - break; - default: - // Do nothing for NONE - break; + + private synchronized void stopPressure() { + running = false; + if (pressureTask != null && !pressureTask.isDone()) { + pressureTask.cancel(true); + pressureTask = null; + } + } + + private void generateGarbage(AllocationMode mode) { + switch (mode.getScenarioType()) { + case STEADY : + executeSteadyLoadScenario(mode); + break; + case GROWING : + executeGrowingHeapScenario(mode); + break; + case PROMOTION : + executePromotionStormScenario(mode); + break; + case FRAGMENTATION : + executeFragmentationScenario(mode); + break; + case CROSS_REF : + executeCrossRefsScenario(mode); + break; + default : + // Do nothing for NONE + break; + } + } + + private void executeSteadyLoadScenario(AllocationMode mode) { + int targetMB = mode.getLiveSetSizeMB(); + int rateMBPerSec = mode.getAllocationRateMBPerSec(); + + // Maintain stable live set (single-threaded) + maintainLiveSet(targetMB); + + // Allocate transient garbage at specified rate across 4 threads + // (100ms iteration = 10 iterations/sec) + int bytesPerIteration = (rateMBPerSec * 1024 * 1024) / 10; + + allocateTransientGarbageMultiThreaded(bytesPerIteration, 4); } - } - - private void executeSteadyLoadScenario(AllocationMode mode) { - int targetMB = mode.getLiveSetSizeMB(); - int rateMBPerSec = mode.getAllocationRateMBPerSec(); - - // Maintain stable live set (single-threaded) - maintainLiveSet(targetMB); - - // Allocate transient garbage at specified rate across 4 threads - // (100ms iteration = 10 iterations/sec) - int bytesPerIteration = (rateMBPerSec * 1024 * 1024) / 10; - - allocateTransientGarbageMultiThreaded(bytesPerIteration, 4); - } - - private void executeGrowingHeapScenario(AllocationMode mode) { - long startTime = scenarioStartTime.get(); - long elapsed = (System.currentTimeMillis() - startTime) / 1000; // seconds - - int startMB = 100; - int targetMB = mode.getLiveSetSizeMB(); - int duration = mode.getGrowthDurationSeconds(); - - // Calculate current target based on linear growth - int currentTargetMB = - elapsed >= duration - ? targetMB - : startMB + (int) ((targetMB - startMB) * elapsed / duration); - - maintainLiveSet(currentTargetMB); - - // Allocate transient garbage - int rateMBPerSec = mode.getAllocationRateMBPerSec(); - int bytesPerIteration = (rateMBPerSec * 1024 * 1024) / 10; - allocateTransientGarbageMultiThreaded(bytesPerIteration, 4); - } - - private void executePromotionStormScenario(AllocationMode mode) { - int rateMBPerSec = mode.getAllocationRateMBPerSec(); - int bytesPerIteration = (rateMBPerSec * 1024 * 1024) / 10; - - // 50% of allocations should survive to old generation - // Allocate half as transient, half as medium-lived - int transientBytes = bytesPerIteration / 2; - int promotableBytes = bytesPerIteration / 2; - - // Multi-threaded allocation for both transient and promotable - allocateTransientGarbageMultiThreaded(transientBytes, 4); - allocatePromotableGarbageMultiThreaded(promotableBytes, 4); - - // Move from thread-safe queue to local live set management - byte[] promoted; - while ((promoted = promotableObjects.poll()) != null) { - liveSet.add(promoted); - liveSetBytesAllocated.addAndGet(promoted.length); + + private void executeGrowingHeapScenario(AllocationMode mode) { + long startTime = scenarioStartTime.get(); + long elapsed = (System.currentTimeMillis() - startTime) / 1000; // seconds + + int startMB = 100; + int targetMB = mode.getLiveSetSizeMB(); + int duration = mode.getGrowthDurationSeconds(); + + // Calculate current target based on linear growth + int currentTargetMB = elapsed >= duration ? targetMB : startMB + (int) ((targetMB - startMB) * elapsed / duration); + + maintainLiveSet(currentTargetMB); + + // Allocate transient garbage + int rateMBPerSec = mode.getAllocationRateMBPerSec(); + int bytesPerIteration = (rateMBPerSec * 1024 * 1024) / 10; + allocateTransientGarbageMultiThreaded(bytesPerIteration, 4); } - // Now trim live set if needed (remove oldest) - long targetBytes = mode.getLiveSetSizeMB() * 1024L * 1024L; - while (liveSetBytesAllocated.get() > targetBytes && !liveSet.isEmpty()) { - byte[] removed = liveSet.removeFirst(); - liveSetBytesAllocated.addAndGet(-removed.length); + private void executePromotionStormScenario(AllocationMode mode) { + int rateMBPerSec = mode.getAllocationRateMBPerSec(); + int bytesPerIteration = (rateMBPerSec * 1024 * 1024) / 10; + + // 50% of allocations should survive to old generation + // Allocate half as transient, half as medium-lived + int transientBytes = bytesPerIteration / 2; + int promotableBytes = bytesPerIteration / 2; + + // Multi-threaded allocation for both transient and promotable + allocateTransientGarbageMultiThreaded(transientBytes, 4); + allocatePromotableGarbageMultiThreaded(promotableBytes, 4); + + // Move from thread-safe queue to local live set management + byte[] promoted; + while ((promoted = promotableObjects.poll()) != null) { + liveSet.add(promoted); + liveSetBytesAllocated.addAndGet(promoted.length); + } + + // Now trim live set if needed (remove oldest) + long targetBytes = mode.getLiveSetSizeMB() * 1024L * 1024L; + while (liveSetBytesAllocated.get() > targetBytes && !liveSet.isEmpty()) { + byte[] removed = liveSet.removeFirst(); + liveSetBytesAllocated.addAndGet(-removed.length); + } } - } - - private void executeFragmentationScenario(AllocationMode mode) { - int targetMB = mode.getLiveSetSizeMB(); - maintainLiveSet(targetMB); - - int rateMBPerSec = mode.getAllocationRateMBPerSec(); - int bytesPerIteration = (rateMBPerSec * 1024 * 1024) / 10; - - allocateFragmentationGarbageMultiThreaded(bytesPerIteration, 4); - } - - private void executeCrossRefsScenario(AllocationMode mode) { - // Maintain old gen holders (these will be promoted after surviving GCs) - maintainCrossRefHolders(mode.getLiveSetSizeMB()); - - int rateMBPerSec = mode.getAllocationRateMBPerSec(); - int bytesPerIteration = (rateMBPerSec * 1024 * 1024) / 10; - - // Create young objects and link them from old gen holders - // This triggers G1's write barriers and remembered set updates - createCrossGenerationalRefsMultiThreaded(bytesPerIteration, 4); - } - - /** - * Maintain RefHolder objects that will live in old generation. Each holder has ~1MB padding and a - * reference slot for young objects. - */ - private void maintainCrossRefHolders(int targetMB) { - long targetBytes = targetMB * 1024L * 1024L; - long currentBytes = crossRefBytesAllocated.get(); - - // Add holders if below target - while (currentBytes < targetBytes) { - RefHolder holder = new RefHolder(1024 * 1024); // ~1MB each - crossRefHolders.add(holder); - currentBytes += 1024 * 1024; - crossRefBytesAllocated.set(currentBytes); + + private void executeFragmentationScenario(AllocationMode mode) { + int targetMB = mode.getLiveSetSizeMB(); + maintainLiveSet(targetMB); + + int rateMBPerSec = mode.getAllocationRateMBPerSec(); + int bytesPerIteration = (rateMBPerSec * 1024 * 1024) / 10; + + allocateFragmentationGarbageMultiThreaded(bytesPerIteration, 4); } - // Remove holders if above target - while (currentBytes > targetBytes && !crossRefHolders.isEmpty()) { - crossRefHolders.removeFirst(); - currentBytes -= 1024 * 1024; - crossRefBytesAllocated.set(currentBytes); + private void executeCrossRefsScenario(AllocationMode mode) { + // Maintain old gen holders (these will be promoted after surviving GCs) + maintainCrossRefHolders(mode.getLiveSetSizeMB()); + + int rateMBPerSec = mode.getAllocationRateMBPerSec(); + int bytesPerIteration = (rateMBPerSec * 1024 * 1024) / 10; + + // Create young objects and link them from old gen holders + // This triggers G1's write barriers and remembered set updates + createCrossGenerationalRefsMultiThreaded(bytesPerIteration, 4); } - } - - /** - * Create young objects and update old→young references. Each reference update triggers G1's write - * barrier, which marks the card table and adds to remembered sets. This is the overhead we're - * testing - C4 has no remembered sets. - */ - private void createCrossGenerationalRefsMultiThreaded(int totalBytes, int numThreads) { - if (crossRefHolders.isEmpty()) { - return; + + /** + * Maintain RefHolder objects that will live in old generation. Each holder has ~1MB padding and a reference slot for young objects. + */ + private void maintainCrossRefHolders(int targetMB) { + long targetBytes = targetMB * 1024L * 1024L; + long currentBytes = crossRefBytesAllocated.get(); + + // Add holders if below target + while (currentBytes < targetBytes) { + RefHolder holder = new RefHolder(1024 * 1024); // ~1MB each + crossRefHolders.add(holder); + currentBytes += 1024 * 1024; + crossRefBytesAllocated.set(currentBytes); + } + + // Remove holders if above target + while (currentBytes > targetBytes && !crossRefHolders.isEmpty()) { + crossRefHolders.removeFirst(); + currentBytes -= 1024 * 1024; + crossRefBytesAllocated.set(currentBytes); + } } - int bytesPerThread = totalBytes / numThreads; - CompletableFuture[] futures = new CompletableFuture[numThreads]; + /** + * Create young objects and update old→young references. Each reference update triggers G1's write barrier, which marks the card table and adds to + * remembered sets. This is the overhead we're testing - C4 has no remembered sets. + */ + private void createCrossGenerationalRefsMultiThreaded(int totalBytes, int numThreads) { + if (crossRefHolders.isEmpty()) { + return; + } - // Convert to array for random access from multiple threads - RefHolder[] holders = crossRefHolders.toArray(new RefHolder[0]); + int bytesPerThread = totalBytes / numThreads; + CompletableFuture[] futures = new CompletableFuture[numThreads]; - for (int t = 0; t < numThreads; t++) { - futures[t] = - CompletableFuture.runAsync( - () -> { + // Convert to array for random access from multiple threads + RefHolder[] holders = crossRefHolders.toArray(new RefHolder[0]); + + for (int t = 0; t < numThreads; t++) { + futures[t] = CompletableFuture.runAsync(() -> { int remaining = bytesPerThread; while (remaining > 0) { - // Create young object (1-4KB each for variety) - int size = ThreadLocalRandom.current().nextInt(1024, 4097); - if (size > remaining) size = remaining; - byte[] youngObj = new byte[size]; - ThreadLocalRandom.current().nextBytes(youngObj); - - // Update random old gen holder to point to this young object - // This write triggers G1's post-write barrier: - // 1. Marks card table entry as dirty - // 2. Adds to remembered set for later scanning - int idx = ThreadLocalRandom.current().nextInt(holders.length); - holders[idx].youngRef = youngObj; - - remaining -= size; + // Create young object (1-4KB each for variety) + int size = ThreadLocalRandom.current().nextInt(1024, 4097); + if (size > remaining) + size = remaining; + byte[] youngObj = new byte[size]; + ThreadLocalRandom.current().nextBytes(youngObj); + + // Update random old gen holder to point to this young object + // This write triggers G1's post-write barrier: + // 1. Marks card table entry as dirty + // 2. Adds to remembered set for later scanning + int idx = ThreadLocalRandom.current().nextInt(holders.length); + holders[idx].youngRef = youngObj; + + remaining -= size; } totalBytesAllocated.addAndGet(bytesPerThread); - }, - executorService); - } + }, executorService); + } - CompletableFuture.allOf(futures).join(); - } + CompletableFuture.allOf(futures).join(); + } - private void allocateTransientGarbageMultiThreaded(int totalBytes, int numThreads) { - int bytesPerThread = totalBytes / numThreads; - CompletableFuture[] futures = new CompletableFuture[numThreads]; + private void allocateTransientGarbageMultiThreaded(int totalBytes, int numThreads) { + int bytesPerThread = totalBytes / numThreads; + CompletableFuture[] futures = new CompletableFuture[numThreads]; - for (int t = 0; t < numThreads; t++) { - futures[t] = - CompletableFuture.runAsync( - () -> { + for (int t = 0; t < numThreads; t++) { + futures[t] = CompletableFuture.runAsync(() -> { // Each thread allocates its share byte[] garbage = new byte[bytesPerThread]; ThreadLocalRandom.current().nextBytes(garbage); // Object is now eligible for GC totalBytesAllocated.addAndGet(bytesPerThread); - }, - executorService); - } + }, executorService); + } - // Wait for all threads to complete - CompletableFuture.allOf(futures).join(); - } + // Wait for all threads to complete + CompletableFuture.allOf(futures).join(); + } - private void allocatePromotableGarbageMultiThreaded(int totalBytes, int numThreads) { - int bytesPerThread = totalBytes / numThreads; - CompletableFuture[] futures = new CompletableFuture[numThreads]; + private void allocatePromotableGarbageMultiThreaded(int totalBytes, int numThreads) { + int bytesPerThread = totalBytes / numThreads; + CompletableFuture[] futures = new CompletableFuture[numThreads]; - for (int t = 0; t < numThreads; t++) { - futures[t] = - CompletableFuture.runAsync( - () -> { + for (int t = 0; t < numThreads; t++) { + futures[t] = CompletableFuture.runAsync(() -> { // Create objects that live long enough to be promoted byte[] obj = new byte[bytesPerThread]; ThreadLocalRandom.current().nextBytes(obj); promotableObjects.add(obj); totalBytesAllocated.addAndGet(bytesPerThread); - }, - executorService); - } + }, executorService); + } - CompletableFuture.allOf(futures).join(); - } + CompletableFuture.allOf(futures).join(); + } - private void allocateFragmentationGarbageMultiThreaded(int totalBytes, int numThreads) { - int bytesPerThread = totalBytes / numThreads; - CompletableFuture[] futures = new CompletableFuture[numThreads]; + private void allocateFragmentationGarbageMultiThreaded(int totalBytes, int numThreads) { + int bytesPerThread = totalBytes / numThreads; + CompletableFuture[] futures = new CompletableFuture[numThreads]; - for (int t = 0; t < numThreads; t++) { - futures[t] = - CompletableFuture.runAsync( - () -> { + for (int t = 0; t < numThreads; t++) { + futures[t] = CompletableFuture.runAsync(() -> { int remaining = bytesPerThread; while (remaining > 0) { - // Small objects 100-1000 bytes - int size = ThreadLocalRandom.current().nextInt(100, 1001); - if (size > remaining) size = remaining; - byte[] garbage = new byte[size]; - ThreadLocalRandom.current().nextBytes(garbage); - remaining -= size; + // Small objects 100-1000 bytes + int size = ThreadLocalRandom.current().nextInt(100, 1001); + if (size > remaining) + size = remaining; + byte[] garbage = new byte[size]; + ThreadLocalRandom.current().nextBytes(garbage); + remaining -= size; } totalBytesAllocated.addAndGet(bytesPerThread); - }, - executorService); - } - - CompletableFuture.allOf(futures).join(); - } + }, executorService); + } - private void maintainLiveSet(int targetMB) { - long targetBytes = targetMB * 1024L * 1024L; - long currentBytes = liveSetBytesAllocated.get(); - - // Add objects if below target - while (currentBytes < targetBytes) { - byte[] obj = new byte[1024 * 1024]; // 1 MB object - ThreadLocalRandom.current().nextBytes(obj); - liveSet.add(obj); - currentBytes += 1024 * 1024; - liveSetBytesAllocated.set(currentBytes); + CompletableFuture.allOf(futures).join(); } - // Remove objects if above target - while (currentBytes > targetBytes && !liveSet.isEmpty()) { - liveSet.removeFirst(); - currentBytes -= 1024 * 1024; - liveSetBytesAllocated.set(currentBytes); + private void maintainLiveSet(int targetMB) { + long targetBytes = targetMB * 1024L * 1024L; + long currentBytes = liveSetBytesAllocated.get(); + + // Add objects if below target + while (currentBytes < targetBytes) { + byte[] obj = new byte[1024 * 1024]; // 1 MB object + ThreadLocalRandom.current().nextBytes(obj); + liveSet.add(obj); + currentBytes += 1024 * 1024; + liveSetBytesAllocated.set(currentBytes); + } + + // Remove objects if above target + while (currentBytes > targetBytes && !liveSet.isEmpty()) { + liveSet.removeFirst(); + currentBytes -= 1024 * 1024; + liveSetBytesAllocated.set(currentBytes); + } } - } - private void logStats() { - long now = System.currentTimeMillis(); - if (now - lastStatsTime >= 5000) { - double elapsedSeconds = (now - lastStatsTime) / 1000.0; - long allocated = totalBytesAllocated.getAndSet(0); - double mbPerSec = (allocated / (1024.0 * 1024.0)) / elapsedSeconds; + private void logStats() { + long now = System.currentTimeMillis(); + if (now - lastStatsTime >= 5000) { + double elapsedSeconds = (now - lastStatsTime) / 1000.0; + long allocated = totalBytesAllocated.getAndSet(0); + double mbPerSec = (allocated / (1024.0 * 1024.0)) / elapsedSeconds; - LOGGER.info( - String.format( - "Memory Pressure Stats - Mode: %s | Allocated: %.2f MB/sec | Live Set: %d MB", - currentMode, mbPerSec, liveSetBytesAllocated.get() / (1024 * 1024))); + LOGGER.info(String.format("Memory Pressure Stats - Mode: %s | Allocated: %.2f MB/sec | Live Set: %d MB", currentMode, mbPerSec, + liveSetBytesAllocated.get() / (1024 * 1024))); - lastStatsTime = now; + lastStatsTime = now; + } } - } - @PreDestroy - public void shutdown() { - LOGGER.info("Shutting down MemoryPressureService"); - stopPressure(); - } + @PreDestroy + public void shutdown() { + LOGGER.info("Shutting down MemoryPressureService"); + stopPressure(); + } - public AllocationMode getCurrentMode() { - return currentMode; - } + public AllocationMode getCurrentMode() { + return currentMode; + } - public boolean isRunning() { - return running; - } + public boolean isRunning() { + return running; + } } diff --git a/src/main/java/fish/payara/trader/pressure/ScenarioType.java b/src/main/java/fish/payara/trader/pressure/ScenarioType.java index cc62cc2..4bb8969 100644 --- a/src/main/java/fish/payara/trader/pressure/ScenarioType.java +++ b/src/main/java/fish/payara/trader/pressure/ScenarioType.java @@ -1,10 +1,5 @@ package fish.payara.trader.pressure; public enum ScenarioType { - NONE, - STEADY, - GROWING, - PROMOTION, - FRAGMENTATION, - CROSS_REF + NONE, STEADY, GROWING, PROMOTION, FRAGMENTATION, CROSS_REF } diff --git a/src/main/java/fish/payara/trader/rest/ApplicationConfig.java b/src/main/java/fish/payara/trader/rest/ApplicationConfig.java index 22bacc1..d668bd9 100644 --- a/src/main/java/fish/payara/trader/rest/ApplicationConfig.java +++ b/src/main/java/fish/payara/trader/rest/ApplicationConfig.java @@ -4,4 +4,5 @@ import jakarta.ws.rs.core.Application; @ApplicationPath("/api") -public class ApplicationConfig extends Application {} +public class ApplicationConfig extends Application { +} diff --git a/src/main/java/fish/payara/trader/rest/GCStatsResource.java b/src/main/java/fish/payara/trader/rest/GCStatsResource.java index bb19229..7018040 100644 --- a/src/main/java/fish/payara/trader/rest/GCStatsResource.java +++ b/src/main/java/fish/payara/trader/rest/GCStatsResource.java @@ -3,11 +3,17 @@ import fish.payara.trader.aeron.MarketDataPublisher; import fish.payara.trader.gc.GCStats; import fish.payara.trader.gc.GCStatsService; +import fish.payara.trader.monitoring.GCPauseMonitor; +import fish.payara.trader.monitoring.SLAMonitorService; import fish.payara.trader.pressure.MemoryPressureService; import jakarta.inject.Inject; -import jakarta.ws.rs.*; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; + import java.lang.management.GarbageCollectorMXBean; import java.lang.management.ManagementFactory; import java.util.HashMap; @@ -20,116 +26,116 @@ @Path("/gc") public class GCStatsResource { - private static final Logger LOGGER = Logger.getLogger(GCStatsResource.class.getName()); + private static final Logger LOGGER = Logger.getLogger(GCStatsResource.class.getName()); + + @Inject + private GCStatsService gcStatsService; - @Inject private GCStatsService gcStatsService; + @Inject + private MemoryPressureService memoryPressureService; - @Inject private MemoryPressureService memoryPressureService; + @Inject + private MarketDataPublisher publisher; - @Inject private MarketDataPublisher publisher; + @Inject + private SLAMonitorService slaMonitor; - @Inject private fish.payara.trader.monitoring.SLAMonitorService slaMonitor; + @Inject + private GCPauseMonitor gcPauseMonitor; - @Inject private fish.payara.trader.monitoring.GCPauseMonitor gcPauseMonitor; + @GET + @Path("/sla") + @Produces(MediaType.APPLICATION_JSON) + public Response getSLAStats() { + return Response.ok(slaMonitor.getStats()).build(); + } - @GET - @Path("/sla") - @Produces(MediaType.APPLICATION_JSON) - public Response getSLAStats() { - return Response.ok(slaMonitor.getStats()).build(); - } + @POST + @Path("/sla/reset") + public Response resetSLAStats() { + slaMonitor.reset(); + return Response.ok(Map.of("status", "reset")).build(); + } - @POST - @Path("/sla/reset") - public Response resetSLAStats() { - slaMonitor.reset(); - return Response.ok(Map.of("status", "reset")).build(); - } + @GET + @Path("/pauses") + @Produces(MediaType.APPLICATION_JSON) + public Response getGCPauseStats() { + return Response.ok(gcPauseMonitor.getStats()).build(); + } - @GET - @Path("/pauses") - @Produces(MediaType.APPLICATION_JSON) - public Response getGCPauseStats() { - return Response.ok(gcPauseMonitor.getStats()).build(); - } + @POST + @Path("/pauses/reset") + public Response resetGCPauseStats() { + gcPauseMonitor.reset(); + return Response.ok(Map.of("status", "reset")).build(); + } - @POST - @Path("/pauses/reset") - public Response resetGCPauseStats() { - gcPauseMonitor.reset(); - return Response.ok(Map.of("status", "reset")).build(); - } + @GET + @Path("/comparison") + @Produces(MediaType.APPLICATION_JSON) + public Response getComparison() { + Map comparison = new HashMap<>(); + + // Identify which instance is responding + String instanceName = System.getenv("PAYARA_INSTANCE_NAME"); + if (instanceName == null) { + instanceName = "standalone"; + } + comparison.put("instanceName", instanceName); + + String jvmVendor = System.getProperty("java.vm.vendor"); + String jvmName = System.getProperty("java.vm.name"); + List gcBeans = ManagementFactory.getGarbageCollectorMXBeans(); + String gcName = gcBeans.stream().map(GarbageCollectorMXBean::getName).collect(Collectors.joining(", ")); + + boolean isAzulC4 = gcName.toLowerCase().contains("c4") || jvmName.toLowerCase().contains("zing"); + + comparison.put("jvmVendor", jvmVendor); + comparison.put("jvmName", jvmName); + comparison.put("gcCollectors", gcName); + comparison.put("isAzulC4", isAzulC4); + comparison.put("heapSizeMB", Runtime.getRuntime().maxMemory() / (1024 * 1024)); + comparison.put("allocationMode", memoryPressureService.getCurrentMode()); + comparison.put("allocationRateMBps", memoryPressureService.getCurrentMode().getAllocationRateMBPerSec()); + comparison.put("messageRate", publisher.getMessagesPublished()); + + List stats = gcStatsService.collectGCStats(); + comparison.put("gcStats", stats); + + fish.payara.trader.monitoring.GCPauseMonitor.GCPauseStats pauseStats = gcPauseMonitor.getStats(); + comparison.put("pauseP50Ms", pauseStats.p50Ms); + comparison.put("pauseP95Ms", pauseStats.p95Ms); + comparison.put("pauseP99Ms", pauseStats.p99Ms); + comparison.put("pauseP999Ms", pauseStats.p999Ms); + comparison.put("pauseMaxMs", pauseStats.maxMs); // All-time max + comparison.put("pauseAvgMs", pauseStats.avgPauseMs); + comparison.put("totalPauseCount", pauseStats.totalPauseCount); + comparison.put("totalPauseTimeMs", pauseStats.totalPauseTimeMs); + + comparison.put("slaViolations10ms", pauseStats.violationsOver10ms); + comparison.put("slaViolations50ms", pauseStats.violationsOver50ms); + comparison.put("slaViolations100ms", pauseStats.violationsOver100ms); + comparison.put("pauseSampleSize", pauseStats.sampleSize); + + return Response.ok(comparison).build(); + } - @GET - @Path("/comparison") - @Produces(MediaType.APPLICATION_JSON) - public Response getComparison() { - Map comparison = new HashMap<>(); + @GET + @Path("/stats") + @Produces(MediaType.APPLICATION_JSON) + public Response getGCStats() { + List stats = gcStatsService.collectGCStats(); + LOGGER.info(String.format("GET /api/gc/stats - Returned %d GC collector stats", stats.size())); + return Response.ok(stats).build(); + } - // Identify which instance is responding - String instanceName = System.getenv("PAYARA_INSTANCE_NAME"); - if (instanceName == null) { - instanceName = "standalone"; + @POST + @Path("/reset") + @Produces(MediaType.APPLICATION_JSON) + public Response resetStats() { + LOGGER.info("POST /api/gc/reset - Resetting GC statistics"); + gcStatsService.resetStats(); + return Response.ok().entity("{\"status\":\"reset\"}").build(); } - comparison.put("instanceName", instanceName); - - String jvmVendor = System.getProperty("java.vm.vendor"); - String jvmName = System.getProperty("java.vm.name"); - List gcBeans = ManagementFactory.getGarbageCollectorMXBeans(); - String gcName = - gcBeans.stream().map(GarbageCollectorMXBean::getName).collect(Collectors.joining(", ")); - - boolean isAzulC4 = - jvmVendor != null && (jvmVendor.contains("Azul") || jvmName.contains("Zing")); - - comparison.put("jvmVendor", jvmVendor); - comparison.put("jvmName", jvmName); - comparison.put("gcCollectors", gcName); - comparison.put("isAzulC4", isAzulC4); - comparison.put("heapSizeMB", Runtime.getRuntime().maxMemory() / (1024 * 1024)); - - comparison.put("allocationMode", memoryPressureService.getCurrentMode()); - comparison.put( - "allocationRateMBps", memoryPressureService.getCurrentMode().getAllocationRateMBPerSec()); - comparison.put("messageRate", publisher.getMessagesPublished()); - - List stats = gcStatsService.collectGCStats(); - comparison.put("gcStats", stats); - - fish.payara.trader.monitoring.GCPauseMonitor.GCPauseStats pauseStats = - gcPauseMonitor.getStats(); - comparison.put("pauseP50Ms", pauseStats.p50Ms); - comparison.put("pauseP95Ms", pauseStats.p95Ms); - comparison.put("pauseP99Ms", pauseStats.p99Ms); - comparison.put("pauseP999Ms", pauseStats.p999Ms); - comparison.put("pauseMaxMs", pauseStats.maxMs); // All-time max - comparison.put("pauseAvgMs", pauseStats.avgPauseMs); - comparison.put("totalPauseCount", pauseStats.totalPauseCount); - comparison.put("totalPauseTimeMs", pauseStats.totalPauseTimeMs); - - comparison.put("slaViolations10ms", pauseStats.violationsOver10ms); - comparison.put("slaViolations50ms", pauseStats.violationsOver50ms); - comparison.put("slaViolations100ms", pauseStats.violationsOver100ms); - comparison.put("pauseSampleSize", pauseStats.sampleSize); - - return Response.ok(comparison).build(); - } - - @GET - @Path("/stats") - @Produces(MediaType.APPLICATION_JSON) - public Response getGCStats() { - List stats = gcStatsService.collectGCStats(); - LOGGER.info(String.format("GET /api/gc/stats - Returned %d GC collector stats", stats.size())); - return Response.ok(stats).build(); - } - - @POST - @Path("/reset") - @Produces(MediaType.APPLICATION_JSON) - public Response resetStats() { - LOGGER.info("POST /api/gc/reset - Resetting GC statistics"); - gcStatsService.resetStats(); - return Response.ok().entity("{\"status\":\"reset\"}").build(); - } } diff --git a/src/main/java/fish/payara/trader/rest/MemoryPressureResource.java b/src/main/java/fish/payara/trader/rest/MemoryPressureResource.java index be0d1ad..9fc49a7 100644 --- a/src/main/java/fish/payara/trader/rest/MemoryPressureResource.java +++ b/src/main/java/fish/payara/trader/rest/MemoryPressureResource.java @@ -13,81 +13,79 @@ @Path("/pressure") public class MemoryPressureResource { - private static final Logger LOGGER = Logger.getLogger(MemoryPressureResource.class.getName()); + private static final Logger LOGGER = Logger.getLogger(MemoryPressureResource.class.getName()); - @Inject private MemoryPressureService pressureService; + @Inject + private MemoryPressureService pressureService; - @GET - @Path("/status") - @Produces(MediaType.APPLICATION_JSON) - public Response getStatus() { - AllocationMode currentMode = pressureService.getCurrentMode(); + @GET + @Path("/status") + @Produces(MediaType.APPLICATION_JSON) + public Response getStatus() { + AllocationMode currentMode = pressureService.getCurrentMode(); - Map status = new HashMap<>(); - status.put("currentMode", currentMode.name()); - status.put("description", currentMode.getDescription()); - status.put("running", pressureService.isRunning()); - status.put("allocationRateMBPerSec", currentMode.getAllocationRateMBPerSec()); - status.put("liveSetSizeMB", currentMode.getLiveSetSizeMB()); - status.put("scenarioType", currentMode.getScenarioType()); - return Response.ok(status).build(); - } + Map status = new HashMap<>(); + status.put("currentMode", currentMode.name()); + status.put("description", currentMode.getDescription()); + status.put("running", pressureService.isRunning()); + status.put("allocationRateMBPerSec", currentMode.getAllocationRateMBPerSec()); + status.put("liveSetSizeMB", currentMode.getLiveSetSizeMB()); + status.put("scenarioType", currentMode.getScenarioType()); + return Response.ok(status).build(); + } - @POST - @Path("/mode/{mode}") - @Produces(MediaType.APPLICATION_JSON) - public Response setMode(@PathParam("mode") String modeStr) { - try { - AllocationMode mode = AllocationMode.valueOf(modeStr.toUpperCase()); - LOGGER.info( - String.format( - "POST /api/pressure/mode/%s - Setting memory pressure mode to: %s", - modeStr, mode.name())); + @POST + @Path("/mode/{mode}") + @Produces(MediaType.APPLICATION_JSON) + public Response setMode(@PathParam("mode") String modeStr) { + try { + AllocationMode mode = AllocationMode.valueOf(modeStr.toUpperCase()); + LOGGER.info(String.format("POST /api/pressure/mode/%s - Setting memory pressure mode to: %s", modeStr, mode.name())); - pressureService.setAllocationMode(mode); + pressureService.setAllocationMode(mode); - Map result = new HashMap<>(); - result.put("success", true); - result.put("mode", mode.name()); - result.put("description", mode.getDescription()); - result.put("allocationRateMBPerSec", mode.getAllocationRateMBPerSec()); - result.put("scenarioType", mode.getScenarioType()); + Map result = new HashMap<>(); + result.put("success", true); + result.put("mode", mode.name()); + result.put("description", mode.getDescription()); + result.put("allocationRateMBPerSec", mode.getAllocationRateMBPerSec()); + result.put("scenarioType", mode.getScenarioType()); - return Response.ok(result).build(); - } catch (IllegalArgumentException e) { - LOGGER.warning(String.format("POST /api/pressure/mode/%s - Invalid mode requested", modeStr)); + return Response.ok(result).build(); + } catch (IllegalArgumentException e) { + LOGGER.warning(String.format("POST /api/pressure/mode/%s - Invalid mode requested", modeStr)); - Map error = new HashMap<>(); - error.put("success", false); - error.put("error", "Invalid mode: " + modeStr); - // Valid modes are all values of AllocationMode - StringBuilder validModes = new StringBuilder(); - for (AllocationMode m : AllocationMode.values()) { - validModes.append(m.name()).append(", "); - } - error.put("validModes", validModes.toString()); - return Response.status(Response.Status.BAD_REQUEST).entity(error).build(); + Map error = new HashMap<>(); + error.put("success", false); + error.put("error", "Invalid mode: " + modeStr); + // Valid modes are all values of AllocationMode + StringBuilder validModes = new StringBuilder(); + for (AllocationMode m : AllocationMode.values()) { + validModes.append(m.name()).append(", "); + } + error.put("validModes", validModes.toString()); + return Response.status(Response.Status.BAD_REQUEST).entity(error).build(); + } } - } - @GET - @Path("/modes") - @Produces(MediaType.APPLICATION_JSON) - public Response getModes() { - LOGGER.fine("GET /api/pressure/modes - Listing all allocation modes"); + @GET + @Path("/modes") + @Produces(MediaType.APPLICATION_JSON) + public Response getModes() { + LOGGER.fine("GET /api/pressure/modes - Listing all allocation modes"); - Map> modes = new HashMap<>(); + Map> modes = new HashMap<>(); - for (AllocationMode mode : AllocationMode.values()) { - Map modeInfo = new HashMap<>(); - modeInfo.put("name", mode.name()); - modeInfo.put("description", mode.getDescription()); - modeInfo.put("allocationRateMBPerSec", mode.getAllocationRateMBPerSec()); - modeInfo.put("liveSetSizeMB", mode.getLiveSetSizeMB()); - modeInfo.put("scenarioType", mode.getScenarioType()); - modes.put(mode.name(), modeInfo); - } + for (AllocationMode mode : AllocationMode.values()) { + Map modeInfo = new HashMap<>(); + modeInfo.put("name", mode.name()); + modeInfo.put("description", mode.getDescription()); + modeInfo.put("allocationRateMBPerSec", mode.getAllocationRateMBPerSec()); + modeInfo.put("liveSetSizeMB", mode.getLiveSetSizeMB()); + modeInfo.put("scenarioType", mode.getScenarioType()); + modes.put(mode.name(), modeInfo); + } - return Response.ok(modes).build(); - } + return Response.ok(modes).build(); + } } diff --git a/src/main/java/fish/payara/trader/rest/StatusResource.java b/src/main/java/fish/payara/trader/rest/StatusResource.java index 46b895c..84ae1c8 100644 --- a/src/main/java/fish/payara/trader/rest/StatusResource.java +++ b/src/main/java/fish/payara/trader/rest/StatusResource.java @@ -18,79 +18,78 @@ @Path("/status") public class StatusResource { - @Inject private AeronSubscriberBean subscriber; + @Inject + private AeronSubscriberBean subscriber; - @Inject private MarketDataPublisher publisher; + @Inject + private MarketDataPublisher publisher; - @Inject private MarketDataBroadcaster broadcaster; + @Inject + private MarketDataBroadcaster broadcaster; - @Inject private HazelcastInstance hazelcastInstance; + @Inject + private HazelcastInstance hazelcastInstance; - @GET - @Produces(MediaType.APPLICATION_JSON) - public Response getStatus() { - Map status = new HashMap<>(); + @GET + @Produces(MediaType.APPLICATION_JSON) + public Response getStatus() { + Map status = new HashMap<>(); - String instanceName = System.getenv("PAYARA_INSTANCE_NAME"); - if (instanceName == null) { - instanceName = "standalone"; + String instanceName = System.getenv("PAYARA_INSTANCE_NAME"); + if (instanceName == null) { + instanceName = "standalone"; + } + + status.put("application", "TradeStreamEE"); + status.put("description", "High-frequency trading dashboard with Aeron and SBE"); + status.put("instance", instanceName); + status.put("subscriber", subscriber.getStatus()); + + // Include both local and cluster-wide message counts + Map publisherStats = new HashMap<>(); + publisherStats.put("localMessagesPublished", publisher.getMessagesPublished()); + publisherStats.put("clusterMessagesPublished", publisher.getClusterMessagesPublished()); + status.put("publisher", publisherStats); + + status.put("websocket", Map.of("activeSessions", broadcaster.getSessionCount())); + status.put("status", "UP"); + + return Response.ok(status).build(); } - status.put("application", "TradeStreamEE"); - status.put("description", "High-frequency trading dashboard with Aeron and SBE"); - status.put("instance", instanceName); - status.put("subscriber", subscriber.getStatus()); - - // Include both local and cluster-wide message counts - Map publisherStats = new HashMap<>(); - publisherStats.put("localMessagesPublished", publisher.getMessagesPublished()); - publisherStats.put("clusterMessagesPublished", publisher.getClusterMessagesPublished()); - status.put("publisher", publisherStats); - - status.put("websocket", Map.of("activeSessions", broadcaster.getSessionCount())); - status.put("status", "UP"); - - return Response.ok(status).build(); - } - - /** Get cluster status and membership information */ - @GET - @Path("/cluster") - @Produces(MediaType.APPLICATION_JSON) - public Response getClusterStatus() { - Map clusterInfo = new HashMap<>(); - - try { - if (hazelcastInstance == null) { - clusterInfo.put("clustered", false); - clusterInfo.put("message", "Running in standalone mode (Hazelcast not available)"); - return Response.ok(clusterInfo).build(); - } - - clusterInfo.put("clustered", true); - clusterInfo.put("clusterSize", hazelcastInstance.getCluster().getMembers().size()); - clusterInfo.put("clusterTime", hazelcastInstance.getCluster().getClusterTime()); - clusterInfo.put( - "localMemberUuid", hazelcastInstance.getCluster().getLocalMember().getUuid().toString()); - - clusterInfo.put( - "members", - hazelcastInstance.getCluster().getMembers().stream() - .map( - member -> - Map.of( - "address", member.getAddress().toString(), - "uuid", member.getUuid().toString(), - "localMember", member.localMember(), - "liteMember", member.isLiteMember())) - .collect(Collectors.toList())); - - return Response.ok(clusterInfo).build(); - - } catch (Exception e) { - clusterInfo.put("clustered", false); - clusterInfo.put("error", e.getMessage()); - return Response.status(Response.Status.INTERNAL_SERVER_ERROR).entity(clusterInfo).build(); + /** Get cluster status and membership information */ + @GET + @Path("/cluster") + @Produces(MediaType.APPLICATION_JSON) + public Response getClusterStatus() { + Map clusterInfo = new HashMap<>(); + + try { + if (hazelcastInstance == null) { + clusterInfo.put("clustered", false); + clusterInfo.put("message", "Running in standalone mode (Hazelcast not available)"); + return Response.ok(clusterInfo).build(); + } + + clusterInfo.put("clustered", true); + clusterInfo.put("clusterSize", hazelcastInstance.getCluster().getMembers().size()); + clusterInfo.put("clusterTime", hazelcastInstance.getCluster().getClusterTime()); + clusterInfo.put("localMemberUuid", hazelcastInstance.getCluster().getLocalMember().getUuid().toString()); + + clusterInfo.put("members", + hazelcastInstance.getCluster() + .getMembers() + .stream() + .map(member -> Map.of("address", member.getAddress().toString(), "uuid", member.getUuid().toString(), "localMember", + member.localMember(), "liteMember", member.isLiteMember())) + .collect(Collectors.toList())); + + return Response.ok(clusterInfo).build(); + + } catch (Exception e) { + clusterInfo.put("clustered", false); + clusterInfo.put("error", e.getMessage()); + return Response.status(Response.Status.INTERNAL_SERVER_ERROR).entity(clusterInfo).build(); + } } - } } diff --git a/src/main/java/fish/payara/trader/websocket/MarketDataBroadcaster.java b/src/main/java/fish/payara/trader/websocket/MarketDataBroadcaster.java index 5dd4370..40e43d3 100644 --- a/src/main/java/fish/payara/trader/websocket/MarketDataBroadcaster.java +++ b/src/main/java/fish/payara/trader/websocket/MarketDataBroadcaster.java @@ -14,169 +14,162 @@ /** * Broadcaster for market data WebSocket messages. * - *

Maintains a set of active WebSocket sessions and broadcasts JSON messages to all connected - * clients. + *

+ * Maintains a set of active WebSocket sessions and broadcasts JSON messages to all connected clients. * - *

In clustered mode, uses Hazelcast distributed topics to broadcast messages across all cluster - * members, ensuring all WebSocket clients receive data regardless of which instance they connect - * to. + *

+ * In clustered mode, uses Hazelcast distributed topics to broadcast messages across all cluster members, ensuring all WebSocket clients receive data regardless + * of which instance they connect to. * - *

Note: JSON string creation intentionally generates garbage to stress-test Azul's Pauseless GC - * (C4). + *

+ * Note: JSON string creation intentionally generates garbage to stress-test Azul's Pauseless GC (C4). */ @ApplicationScoped public class MarketDataBroadcaster { - private static final Logger LOGGER = Logger.getLogger(MarketDataBroadcaster.class.getName()); - private static final String TOPIC_NAME = "market-data-broadcast"; - - private final Set sessions = ConcurrentHashMap.newKeySet(); - - @Inject private HazelcastInstance hazelcastInstance; - - @Inject private fish.payara.trader.monitoring.SLAMonitorService slaMonitor; - - private ITopic clusterTopic; - - // Statistics - private long messagesSent = 0; - private long lastStatsTime = System.currentTimeMillis(); - - /** Initialize Hazelcast topic subscription for cluster-wide broadcasting */ - @PostConstruct - public void init() { - try { - if (hazelcastInstance != null) { - clusterTopic = hazelcastInstance.getTopic(TOPIC_NAME); - clusterTopic.addMessageListener( - message -> { - broadcastLocal(message.getMessageObject()); - }); - LOGGER.info("Subscribed to Hazelcast topic: " + TOPIC_NAME + " (clustered mode)"); - } else { - LOGGER.info("Hazelcast not available - running in standalone mode"); - } - } catch (Exception e) { - LOGGER.log(Level.SEVERE, "Failed to initialize Hazelcast topic subscription", e); - // Continue without clustering + private static final Logger LOGGER = Logger.getLogger(MarketDataBroadcaster.class.getName()); + private static final String TOPIC_NAME = "market-data-broadcast"; + + private final Set sessions = ConcurrentHashMap.newKeySet(); + + @Inject + private HazelcastInstance hazelcastInstance; + + @Inject + private fish.payara.trader.monitoring.SLAMonitorService slaMonitor; + + private ITopic clusterTopic; + + // Statistics + private long messagesSent = 0; + private long lastStatsTime = System.currentTimeMillis(); + + /** Initialize Hazelcast topic subscription for cluster-wide broadcasting */ + @PostConstruct + public void init() { + try { + if (hazelcastInstance != null) { + clusterTopic = hazelcastInstance.getTopic(TOPIC_NAME); + clusterTopic.addMessageListener(message -> { + broadcastLocal(message.getMessageObject()); + }); + LOGGER.info("Subscribed to Hazelcast topic: " + TOPIC_NAME + " (clustered mode)"); + } else { + LOGGER.info("Hazelcast not available - running in standalone mode"); + } + } catch (Exception e) { + LOGGER.log(Level.SEVERE, "Failed to initialize Hazelcast topic subscription", e); + // Continue without clustering + } } - } - - /** Register a new WebSocket session */ - public void addSession(Session session) { - sessions.add(session); - LOGGER.info("WebSocket session added. Total sessions: " + sessions.size()); - } - - public void removeSession(Session session) { - sessions.remove(session); - LOGGER.info("WebSocket session removed. Total sessions: " + sessions.size()); - } - - /** - * Broadcast JSON message to all connected clients across the cluster. - * - *

In clustered mode, publishes to Hazelcast topic which distributes to all cluster members. - * Each member then broadcasts to its local WebSocket sessions. - * - *

In standalone mode, broadcasts directly to local sessions. - * - *

This method intentionally creates string allocations to generate garbage and stress the - * garbage collector. - */ - public void broadcast(String jsonMessage) { - if (clusterTopic != null) { - try { - clusterTopic.publish(jsonMessage); - } catch (Exception e) { - LOGGER.log( - Level.SEVERE, - "Failed to publish to Hazelcast topic, falling back to local broadcast", - e); - broadcastLocal(jsonMessage); - } - } else { - broadcastLocal(jsonMessage); + + /** Register a new WebSocket session */ + public void addSession(Session session) { + sessions.add(session); + LOGGER.info("WebSocket session added. Total sessions: " + sessions.size()); } - } - - /** - * Broadcast message to local WebSocket sessions only. Called either in standalone mode or by - * Hazelcast topic listener. - */ - private void broadcastLocal(String jsonMessage) { - if (sessions.isEmpty()) { - return; + + public void removeSession(Session session) { + sessions.remove(session); + LOGGER.info("WebSocket session removed. Total sessions: " + sessions.size()); } - long startTime = System.currentTimeMillis(); - - sessions.removeIf( - session -> { - if (!session.isOpen()) { - return true; - } - try { - session.getAsyncRemote().sendText(jsonMessage); - return false; - } catch (Exception e) { - LOGGER.log(Level.WARNING, "Failed to send message", e); - return true; - } + /** + * Broadcast JSON message to all connected clients across the cluster. + * + *

+ * In clustered mode, publishes to Hazelcast topic which distributes to all cluster members. Each member then broadcasts to its local WebSocket sessions. + * + *

+ * In standalone mode, broadcasts directly to local sessions. + * + *

+ * This method intentionally creates string allocations to generate garbage and stress the garbage collector. + */ + public void broadcast(String jsonMessage) { + if (clusterTopic != null) { + try { + clusterTopic.publish(jsonMessage); + } catch (Exception e) { + LOGGER.log(Level.SEVERE, "Failed to publish to Hazelcast topic, falling back to local broadcast", e); + broadcastLocal(jsonMessage); + } + } else { + broadcastLocal(jsonMessage); + } + } + + /** + * Broadcast message to local WebSocket sessions only. Called either in standalone mode or by Hazelcast topic listener. + */ + private void broadcastLocal(String jsonMessage) { + if (sessions.isEmpty()) { + return; + } + + long startTime = System.currentTimeMillis(); + + sessions.removeIf(session -> { + if (!session.isOpen()) { + return true; + } + try { + session.getAsyncRemote().sendText(jsonMessage); + return false; + } catch (Exception e) { + LOGGER.log(Level.WARNING, "Failed to send message", e); + return true; + } }); - long latency = System.currentTimeMillis() - startTime; - if (slaMonitor != null) { - slaMonitor.recordOperation(latency); + long latency = System.currentTimeMillis() - startTime; + if (slaMonitor != null) { + slaMonitor.recordOperation(latency); + } + + logStatistics(); + } + + /** + * Broadcast to all clients with additional garbage generation for GC stress. + * + *

+ * This method wraps the original message in a larger JSON envelope with padding. This increases both memory allocation (String construction) and network + * bandwidth usage, simulating a heavier protocol or inefficient data packaging. + */ + public void broadcastWithArtificialLoad(String jsonMessage) { + String padding = "X".repeat(1024); + + // Wrap the original message in a new JSON structure + // We use StringBuilder to explicitly construct the new JSON string + String enrichedMessage = new StringBuilder(jsonMessage.length() + padding.length() + 100).append("{\"wrapped\":true,") + .append("\"timestamp\":") + .append(System.nanoTime()) + .append(",") + .append("\"padding\":\"") + .append(padding) + .append("\",") + .append("\"data\":") + .append(jsonMessage) + .append("}") + .toString(); + + broadcast(enrichedMessage); + } + + /** Get count of active sessions */ + public int getSessionCount() { + return sessions.size(); } - logStatistics(); - } - - /** - * Broadcast to all clients with additional garbage generation for GC stress. - * - *

This method wraps the original message in a larger JSON envelope with padding. This - * increases both memory allocation (String construction) and network bandwidth usage, simulating - * a heavier protocol or inefficient data packaging. - */ - public void broadcastWithArtificialLoad(String jsonMessage) { - String padding = "X".repeat(1024); - - // Wrap the original message in a new JSON structure - // We use StringBuilder to explicitly construct the new JSON string - String enrichedMessage = - new StringBuilder(jsonMessage.length() + padding.length() + 100) - .append("{\"wrapped\":true,") - .append("\"timestamp\":") - .append(System.nanoTime()) - .append(",") - .append("\"padding\":\"") - .append(padding) - .append("\",") - .append("\"data\":") - .append(jsonMessage) - .append("}") - .toString(); - - broadcast(enrichedMessage); - } - - /** Get count of active sessions */ - public int getSessionCount() { - return sessions.size(); - } - - /** Log statistics periodically */ - private void logStatistics() { - long now = System.currentTimeMillis(); - if (now - lastStatsTime > 10000) { // Log every 10 seconds - LOGGER.info( - String.format( - "WebSocket Stats - Active sessions: %d, Messages sent: %,d (%.1f msg/sec)", - sessions.size(), messagesSent, messagesSent / ((now - lastStatsTime) / 1000.0))); - lastStatsTime = now; - messagesSent = 0; + /** Log statistics periodically */ + private void logStatistics() { + long now = System.currentTimeMillis(); + if (now - lastStatsTime > 10000) { // Log every 10 seconds + LOGGER.info(String.format("WebSocket Stats - Active sessions: %d, Messages sent: %,d (%.1f msg/sec)", sessions.size(), messagesSent, + messagesSent / ((now - lastStatsTime) / 1000.0))); + lastStatsTime = now; + messagesSent = 0; + } } - } } diff --git a/src/main/java/fish/payara/trader/websocket/MarketDataWebSocket.java b/src/main/java/fish/payara/trader/websocket/MarketDataWebSocket.java index c1978e6..2a4840e 100644 --- a/src/main/java/fish/payara/trader/websocket/MarketDataWebSocket.java +++ b/src/main/java/fish/payara/trader/websocket/MarketDataWebSocket.java @@ -10,60 +10,55 @@ /** * WebSocket endpoint for streaming market data to clients. * - *

Clients connect to ws://host:port/context/market-data and receive real-time JSON market data - * updates. + *

+ * Clients connect to ws://host:port/context/market-data and receive real-time JSON market data updates. */ @ServerEndpoint("/market-data") public class MarketDataWebSocket { - private static final Logger LOGGER = Logger.getLogger(MarketDataWebSocket.class.getName()); + private static final Logger LOGGER = Logger.getLogger(MarketDataWebSocket.class.getName()); - @Inject private MarketDataBroadcaster broadcaster; + @Inject + private MarketDataBroadcaster broadcaster; - @Inject - @ConfigProperty(name = "TRADER_INGESTION_MODE", defaultValue = "AERON") - private String ingestionMode; + @Inject + @ConfigProperty(name = "TRADER_INGESTION_MODE", defaultValue = "AERON") + private String ingestionMode; - @OnOpen - public void onOpen(Session session) { - LOGGER.info("WebSocket connection opened: " + session.getId()); - broadcaster.addSession(session); + @OnOpen + public void onOpen(Session session) { + LOGGER.info("WebSocket connection opened: " + session.getId()); + broadcaster.addSession(session); - try { - String welcomeJson = - String.format( - "{\"type\":\"info\",\"message\":\"Connected to TradeStreamEE market data feed\",\"mode\":\"%s\"}", - ingestionMode); - session.getBasicRemote().sendText(welcomeJson); - } catch (Exception e) { - LOGGER.log(Level.WARNING, "Failed to send welcome message", e); + try { + String welcomeJson = String.format("{\"type\":\"info\",\"message\":\"Connected to TradeStreamEE market data feed\",\"mode\":\"%s\"}", + ingestionMode); + session.getBasicRemote().sendText(welcomeJson); + } catch (Exception e) { + LOGGER.log(Level.WARNING, "Failed to send welcome message", e); + } } - } - @OnClose - public void onClose(Session session, CloseReason closeReason) { - LOGGER.info( - "WebSocket connection closed: " - + session.getId() - + ", reason: " - + closeReason.getReasonPhrase()); - broadcaster.removeSession(session); - } + @OnClose + public void onClose(Session session, CloseReason closeReason) { + LOGGER.info("WebSocket connection closed: " + session.getId() + ", reason: " + closeReason.getReasonPhrase()); + broadcaster.removeSession(session); + } - @OnError - public void onError(Session session, Throwable throwable) { - LOGGER.log(Level.WARNING, "WebSocket error for session: " + session.getId(), throwable); - broadcaster.removeSession(session); - } + @OnError + public void onError(Session session, Throwable throwable) { + LOGGER.log(Level.WARNING, "WebSocket error for session: " + session.getId(), throwable); + broadcaster.removeSession(session); + } - @OnMessage - public void onMessage(String message, Session session) { - LOGGER.fine("Received message from client: " + message); + @OnMessage + public void onMessage(String message, Session session) { + LOGGER.fine("Received message from client: " + message); - try { - session.getBasicRemote().sendText("{\"type\":\"ack\",\"message\":\"Message received\"}"); - } catch (Exception e) { - LOGGER.log(Level.WARNING, "Failed to send acknowledgment", e); + try { + session.getBasicRemote().sendText("{\"type\":\"ack\",\"message\":\"Message received\"}"); + } catch (Exception e) { + LOGGER.log(Level.WARNING, "Failed to send acknowledgment", e); + } } - } } diff --git a/src/main/webapp/blog.html b/src/main/webapp/blog.html index da08d2b..52fcf2e 100644 --- a/src/main/webapp/blog.html +++ b/src/main/webapp/blog.html @@ -287,15 +287,13 @@

Azul C4 and Payara: The Ideal Base for High-Speed Financial Applications

-

In high-frequency trading (HFT) and financial services, every microsecond counts. The difference between profit and loss often comes down to how quickly your system can process market data and execute trades. For years, Java has been viewed with skepticism in the world of low-latency finance, primarily due to its notorious "stop-the-world" garbage collection pauses that can wreck predictable performance.

+

In high-frequency trading (HFT) and financial services, latency determines profitability. The difference between profit and loss comes down to how quickly your system can process market data and execute trades. For years, Java has been viewed with skepticism in low-latency finance, primarily due to its "stop-the-world" garbage collection pauses that can wreck predictable performance.

-

But what if I told you that Java is now one of the best choices for latency-sensitive financial applications?

- -

I developed TradeStreamEE, a sophisticated high-frequency trading dashboard to demonstrate that HFT is not only possible but also highly performant in Enterprise Java, particularly when combining Azul Platform Prime with the C4 garbage collector and Payara Micro. This technology stack changes Java's performance profile, offering measurable, real-world improvements that are now leading financial institutions to reconsider their current technology choices.

+

Java is now a viable platform for latency-sensitive financial applications. TradeStreamEE, a high-frequency trading reference architecture, shows HFT-class performance on Enterprise Java by combining Azul Platform Prime with the C4 garbage collector and Payara Micro. This technology stack changes Java's performance profile, offering measurable improvements that make Enterprise Java competitive for low-latency workloads.

The Evidence: Scenario-Based GC Stress Testing

-

Rather than applying generic high-allocation pressure, TradeStreamEE uses scenario-based testing that targets specific garbage collector weaknesses. Each scenario exposes fundamental algorithmic differences between G1 and C4.

+

TradeStreamEE uses scenario-based testing that targets specific garbage collector weaknesses rather than applying generic high-allocation pressure. Each scenario exposes fundamental algorithmic differences between G1 and C4.

The test scenarios include:

@@ -307,7 +305,7 @@

The Evidence: Scenario-Based GC Stress Testing

  • CROSS_GEN_REFS: 150 MB/sec with 800 MB in old generation holding references to young objects. Tests remembered set overhead.
  • -

    Each scenario uses 4 parallel threads to generate allocation load, mirroring production web servers and trading systems where multiple threads compete for heap space. The allocation rates (100-300 MB/sec) are calibrated to demonstrate normal GC behavior rather than pathological thrashing, ensuring consistent results across 2GB-4GB heap configurations.

    +

    Each scenario uses 4 parallel threads to generate allocation load, mirroring production web servers and trading systems where multiple threads compete for heap space. The allocation rates (100-300 MB/sec) are calibrated to show normal GC behavior rather than pathological thrashing, ensuring consistent results across 2GB-4GB heap configurations.

    Here is the raw JSON returned from our Payara endpoints during a PROMOTION_STORM scenario:

    @@ -328,20 +326,20 @@

    The Evidence: Scenario-Based GC Stress Testing

    The Results

    -

    Under PROMOTION_STORM (300 MB/sec with high old generation traffic), the application's garbage collection pauses peaked at 1 millisecond. The 50th percentile (median) was 0ms. G1 under the same scenario exhibited pauses of 50-200ms as mixed collections struggled to keep pace with promotion rates.

    +

    Under PROMOTION_STORM (300 MB/sec with high old generation traffic), the application's garbage collection pauses peaked at 1 millisecond. The 50th percentile (median) was 0ms. In comparable testing, G1 showed significantly higher pause times as mixed collections struggled to keep pace with promotion rates.

    Payara Micro: Enterprise Jakarta EE for Demanding Applications

    -

    While Azul C4 eliminates Java's performance limitations, Payara Micro provides the enterprise-grade Jakarta EE runtime that makes sophisticated financial applications possible.

    +

    While Azul C4 addresses Java's GC limitations, Payara Micro provides the enterprise-grade Jakarta EE runtime that enables sophisticated financial applications.

    -

    What makes Payara Micro well suited for financial services is its thoughtful balance of enterprise features and performance optimization. The platform weighs just 100MB and starts in seconds, making it perfect for the microservices architectures that dominate modern financial technology stacks. But do not be deceived by the small footprint. It packs the full power of Jakarta EE 11, featuring critical implementations like Jakarta CDI for dependency injection, WebSocket support for real-time communication, and Jakarta REST for RESTful APIs.

    +

    Payara Micro balances enterprise features with performance optimization. The runtime weighs approximately 100MB and starts in seconds, making it suitable for the microservices architectures common in modern financial technology stacks. Despite the small footprint, it provides the full capabilities of Jakarta EE 11, including Jakarta CDI for dependency injection, WebSocket support for real-time communication, and Jakarta REST for RESTful APIs.

    Built-in Clustering with Hazelcast

    -

    Using Hazelcast under the hood, Payara provides automatic data replication across cluster nodes. Through such deep integration, creating a cluster of Payara Micro instances is a breeze.

    +

    Using Hazelcast under the hood, Payara provides automatic data replication across cluster nodes. Through this integration, creating a cluster of Payara Micro instances requires minimal configuration.

    -

    In our code, we didn't have to write complex distributed consensus logic. We simply injected the Hazelcast instance to track global throughput across the cluster:

    +

    In our code, we injected the Hazelcast instance to track global throughput across the cluster without writing distributed consensus logic:

    // Payara + Hazelcast: Instant cluster-wide metrics without complex setup
     @Inject private HazelcastInstance hazelcastInstance;
    @@ -353,15 +351,15 @@ 

    Built-in Clustering with Hazelcast

    hazelcastInstance.getCPSubsystem().getAtomicLong("cluster-message-count"); }
    -

    This means financial firms can build highly available trading systems that can withstand node failures without losing market data or transaction state.

    +

    Financial firms can build highly available trading systems that withstand node failures without losing market data or transaction state.

    -

    Architecture Deep Dive Into TradeStreamEE

    +

    Architecture Deep Dive: TradeStreamEE

    -

    The TradeStreamEE application serves as a perfect showcase for what's possible when you combine Azul C4 and Payara Micro. The architecture demonstrates thoughtful engineering that addresses the specific challenges of financial trading systems.

    +

    The TradeStreamEE application shows what is possible when you combine Azul C4 and Payara Micro. The architecture addresses the specific challenges of financial trading systems.

    -

    The "Hero": Zero-Copy SBE

    +

    Core Technology: Zero-Copy SBE

    -

    At the core of the system is Aeron IPC with Simple Binary Encoding (SBE), technologies that enable zero-copy message processing. The approach is elegant: instead of creating new Java objects for every market data message, the system uses "flyweight" objects that act as windows over shared memory buffers. This eliminates object allocation entirely during the critical path of message processing.

    +

    At the core of the system is Aeron IPC with Simple Binary Encoding (SBE), technologies that enable zero-copy message processing. Instead of creating new Java objects for every market data message, the system uses "flyweight" objects that act as views over shared memory buffers. This eliminates object allocation during the critical path of message processing.

    // SBE Flyweight pattern - zero object allocation during processing
     private final TradeDecoder tradeDecoder = new TradeDecoder();
    @@ -371,7 +369,7 @@ 

    The "Hero": Zero-Copy SBE

    public void onFragment(DirectBuffer buffer, int offset, int length, Header header) { headerDecoder.wrap(buffer, offset); - // Move the "window" to the data without allocating a new object + // Move the "view" to the data without allocating a new object tradeDecoder.wrap(buffer, offset + headerDecoder.encodedLength(), headerDecoder.blockLength(), headerDecoder.version()); @@ -381,11 +379,11 @@

    The "Hero": Zero-Copy SBE

    final long quantity = tradeDecoder.quantity(); }
    -

    The "Villain": Scenario-Based Memory Pressure

    +

    Stress Testing: Scenario-Based Memory Pressure

    -

    To demonstrate collector behavior under specific pathological conditions, the MemoryPressureService implements five distinct scenarios. Each targets a known G1 weakness while C4 handles the workload without pause.

    +

    To show collector behavior under specific pathological conditions, the MemoryPressureService implements five distinct scenarios. Each targets a known G1 weakness while C4 handles the workload without pause.

    -

    The CROSS_GEN_REFS scenario is particularly instructive. It creates old generation objects that hold references to newly allocated young objects, triggering G1's write barriers and remembered set maintenance:

    +

    The CROSS_GEN_REFS scenario creates old generation objects that hold references to newly allocated young objects, triggering G1's write barriers and remembered set maintenance:

    // Cross-generational reference stress test
     private static class RefHolder {
    @@ -413,28 +411,28 @@ 

    The "Villain": Scenario-Based Memory Pressure

    The Business Impact

    -

    The business implications of this technology combination extend far beyond technical performance metrics. For trading firms, predictable latency directly translates to competitive advantage in the market.

    +

    The business implications of this technology combination extend beyond technical performance metrics. For trading firms, predictable latency directly translates to competitive advantage in the market.

    -

    Perhaps more importantly, the operational complexity that has traditionally plagued Java performance tuning virtually disappears. The standard approach to Java latency problems involved teams of performance engineers spending months tuning JVM parameters (-XX:NewRatio, -XX:SurvivorRatio). With Azul C4 and Payara Micro, most of that complexity evaporates. C4 adapts automatically to workload patterns, eliminating the need for manual parameter tuning.

    +

    Operational complexity also decreases significantly. Traditional Java performance tuning often requires considerable effort to optimize JVM parameters (-XX:NewRatio, -XX:SurvivorRatio). With Azul C4 and Payara Micro, C4 adapts automatically to workload patterns, eliminating the need for manual parameter tuning.

    -

    In our tests, a single Payara Micro instance effortlessly handled ingestion rates exceeding 30 million messages, with zero degradation in SLA compliance. This means financial firms can process more trades per second, handle higher client volumes without performance degradation, and maintain consistent service levels even during market stress events.

    +

    In our tests, a single Payara Micro instance handled sustained ingestion at rates exceeding 30,000 messages per second with zero GC pauses exceeding 1ms. Financial firms can process more trades per second, handle higher client volumes without performance degradation, and maintain consistent service levels during market stress events.

    Conclusion

    -

    Azul C4 and Payara Micro fundamentally redefine the potential of Java for low-latency financial applications. This is more than an incremental gain; it's a paradigm shift.

    +

    Azul C4 and Payara Micro redefine the capabilities of Java for low-latency financial applications. This represents a fundamental shift in what is possible with Enterprise Java.

    -

    By combining the operational maturity of Payara with the runtime supremacy of Azul Platform Prime, organizations can have their cake and eat it too. You can build maintainable, secure, and scalable Jakarta EE applications that meet the most demanding latency SLAs of the financial industry.

    +

    By combining the operational maturity of Payara with the superior runtime performance of Azul Platform Prime, organizations can achieve both development productivity and runtime performance. You can build maintainable, secure, and scalable Jakarta EE applications that meet the strict latency SLAs required by financial trading systems.

    -

    The question is no longer whether Java can handle low-latency financial workloads. With Azul C4 and Payara, it's now one of the best choices available.

    +

    Java can handle low-latency financial workloads. With Azul C4 and Payara, it is a competitive choice.

    Ready to See It in Action?

    -

    The theory is compelling, but the real proof is in the running system. I encourage you to:

    +

    The theory is compelling, but the real proof is in the running system. Explore the following:

      -
    • Explore the Code: Check out the TradeStreamEE repository on GitHub to see the complete implementation. The codebase demos best practices for low-latency Java development.
    • -
    • Try Payara Micro: Download Payara Micro and experience the lightweight, enterprise-grade application server for yourself.
    • -
    • Try Payara Qube: Deploy to Payara Qube (formerly Payara Cloud) for effortless cloud deployment without the infrastructure overhead.
    • -
    • Evaluate Azul Platform Prime: Request a trial of Azul Platform Prime to see C4 garbage collection in action. The performance improvements are immediately noticeable, especially under memory pressure.
    • +
    • Explore the Code: Check out the TradeStreamEE repository on GitHub to see the complete implementation. The codebase shows best practices for low-latency Java development.
    • +
    • Try Payara Micro: Download Payara Micro and experience the lightweight, enterprise-grade application server.
    • +
    • Try Payara Qube: Deploy to Payara Qube (formerly Payara Cloud) for cloud deployment without infrastructure overhead.
    • +
    • Evaluate Azul Platform Prime: Request a trial of Azul Platform Prime to see C4 garbage collection in action. The performance improvements are noticeable under memory pressure.
    • Run Your Own Tests: Use the built-in scenario-based stress testing in TradeStreamEE. The five scenarios (STEADY_LOAD, GROWING_HEAP, PROMOTION_STORM, FRAGMENTATION, CROSS_GEN_REFS) target specific GC weaknesses, allowing direct comparison between G1 and C4 behavior under controlled conditions.
    diff --git a/src/main/webapp/images/dashboard.png b/src/main/webapp/images/dashboard.png new file mode 100644 index 0000000..ca0cb70 Binary files /dev/null and b/src/main/webapp/images/dashboard.png differ diff --git a/src/main/webapp/presentation.html b/src/main/webapp/presentation.html index a1e1413..d851726 100644 --- a/src/main/webapp/presentation.html +++ b/src/main/webapp/presentation.html @@ -218,6 +218,13 @@

    Azul C4 + Payara Micro for Low-Latency Trading

    + +
    +

    Part 1

    +

    The Problem

    +

    Why GC pauses matter in financial systems

    +
    +

    The Problem: GC Pauses in Financial Systems

    @@ -226,7 +233,7 @@

    Why Traditional Java Struggles with HFT

    The Challenge

      -
    • HFT processes millions of messages/second
    • +
    • HFT processes 50,000-100,000+ messages/second
    • Latency budgets measured in microseconds
    • Single 50ms pause = missed trades
    • Unpredictable latency violates SLAs
    • @@ -348,30 +355,31 @@

      Compaction Pauses

      -

      Azul C4: Concurrent by Design

      +

      Azul C4/GPGC: Concurrent by Design

      How Pauseless Collection Works

      -

      Read Barriers

      +

      Read Barriers (LVB)

        -
      • Check object state on every read
      • +
      • Loaded Value Barrier on every read
      • Relocate objects on-the-fly
      • -
      • No stop-the-world compaction
      • -
      • Hardware-assisted (LVB)
      • +
      • Enables concurrent compaction
      • +
      • Small overhead, huge benefit
      -

      No Generations

      +

      Concurrent Generations

        -
      • Single heap region
      • -
      • No remembered sets needed
      • -
      • No write barrier overhead
      • -
      • Simpler, more predictable
      • +
      • Has young (New) and old generations
      • +
      • Both collected concurrently
      • +
      • No STW for generational collection
      • +
      • No remembered set scanning pauses
      -

      Result: Pause times remain under 1ms regardless of heap size or live set.

      +

      Result: Sub-millisecond pauses regardless of heap size or live set.

      +

      C4 = algorithm name; GPGC (Generational Pauseless GC) = collector implementation in JMX

      @@ -453,6 +461,13 @@

      Testing G1's Write Barrier Overhead

    + +
    +

    Part 2

    +

    Architecture

    +

    Zero-copy pipeline design with Jakarta EE

    +
    +

    TradeStreamEE Architecture

    @@ -488,6 +503,88 @@

    Application Layer

    + +
    +

    Dual-Mode Ingestion

    +

    AERON vs DIRECT: Controlled Comparison

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    AspectAERON ModeDIRECT Mode
    EncodingSBE binaryJSON strings
    TransportShared memory IPCIn-process calls
    Allocation~0 bytes/message~1KB+ per message
    GC PressureMinimalHigh (intentional)
    PurposeProduction patternGC stress baseline
    +
    +

    DIRECT mode adds 1KB+ padding per message to stress the garbage collector. This isolates GC as the variable when comparing C4 vs G1.

    +
    +
    + + +
    +

    SBE Schema: 5 Message Types

    +

    Defined in market-data.xml

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    MessageIDKey Fields
    Trade1timestamp, tradeId, price, quantity, side, symbol
    Quote2timestamp, bidPrice, bidSize, askPrice, askSize, symbol
    MarketDepth3bids[], asks[] (5 levels), sequenceNumber
    OrderAck4orderId, execType, leavesQty, cumQty, side
    Heartbeat5timestamp, sequenceNumber
    +

    + Price encoding: int64 × 10,000 for 4 decimal places. Little-endian byte order. +

    +
    +

    SBE: Zero-Copy Message Processing

    @@ -541,18 +638,61 @@

    Aeron IPC

  • Direct memory access
  • Zero-copy with SBE
  • No kernel involvement
  • -
  • Latency: sub-microsecond
  • +
  • Transport latency: sub-microsecond
  • Aeron's ring buffer in shared memory allows components on the same machine to communicate without network overhead. Combined with SBE, the entire path from publisher to subscriber involves no object allocation.

    +

    Note: End-to-end latency includes WebSocket broadcast and JSON conversion. Transport layer is sub-microsecond.

    +
    +
    + + +
    +

    Aeron Configuration Details

    +

    Production-Ready Settings

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    ParameterValuePurpose
    Channelaeron:ipcInter-process communication mode
    Stream ID1001Logical message stream identifier
    Fragment Limit10Messages polled per iteration
    Idle StrategyBackoffIdleStrategy100ns → 100μs adaptive backoff
    Shared Memory512 MBIPC buffer size (Docker tmpfs)
    +
    +

    Circuit Breaker

    +

    Publisher stops after 50 consecutive offer failures to prevent resource exhaustion.

    -

    Payara Micro: Enterprise Runtime

    +

    Payara Micro 7.2025.2

    Jakarta EE 11 for Financial Applications

    @@ -569,10 +709,10 @@

    Features

    Clustering

      -
    • Hazelcast built-in
    • -
    • Automatic discovery
    • -
    • Distributed counters
    • -
    • Session replication
    • +
    • Hazelcast 5.5 built-in
    • +
    • TCP/IP discovery (Docker)
    • +
    • CP Subsystem for counters
    • +
    • Reliable Topics (back-pressure)
    • No external infrastructure
    @@ -593,8 +733,9 @@

    Clustering

    Jakarta Concurrency 3.1

    Virtual Threads for Memory Pressure Testing

    
    -@Resource
    -private ManagedExecutorService executorService;
    +@Inject
    +@VirtualThreadExecutor  // Jakarta Concurrency 3.1 - virtual threads
    +private ManagedExecutorService managedExecutorService;
     
     private void allocateTransientGarbageMultiThreaded(int totalBytes, int numThreads) {
         int bytesPerThread = totalBytes / numThreads;
    @@ -605,7 +746,7 @@ 

    Virtual Threads for Memory Pressure Testing

    byte[] garbage = new byte[bytesPerThread]; ThreadLocalRandom.current().nextBytes(garbage); totalBytesAllocated.addAndGet(bytesPerThread); - }, executorService); + }, managedExecutorService); } CompletableFuture.allOf(futures).join(); @@ -616,34 +757,53 @@

    Virtual Threads for Memory Pressure Testing

    + +
    +

    Part 3

    +

    Test Results

    +

    Measured GC behavior under stress scenarios

    +
    +

    Test Results: G1 vs C4

    Under PROMOTION_STORM (300 MB/sec, 50% survival)

    -
    50-200ms
    -
    G1 Mixed Collection Pauses
    +
    25ms
    +
    G1 Maximum Pause (measured)
    -
    < 1ms
    -
    C4 Maximum Pause
    +
    2ms
    +
    C4 Maximum Pause (measured)
    -
    
    +            
    +
    +

    G1GC (Zulu 21)

    +
    
     {
    -  "currentMode": "PROMOTION_STORM",
    -  "allocationRateMBPerSec": 300,
    -  "liveSetSizeMB": 1024,
    -  "percentiles": {
    -    "p50": 0,
    -    "p99": 1,
    -    "max": 1
    -  }
    +  "pauseP50Ms": 3, "pauseP95Ms": 16,
    +  "pauseP99Ms": 24, "pauseMaxMs": 25,
    +  "avgPauseMs": 4.87,
    +  "slaViolations10ms": 20
     }
    -            
    -

    - C4 maintains sub-millisecond pauses regardless of promotion rate or live set size. +

    + +
    +

    C4/GPGC (Prime 21)

    +
    
    +{
    +  "pauseP50Ms": 0, "pauseP95Ms": 1,
    +  "pauseP99Ms": 1, "pauseMaxMs": 2,
    +  "avgPauseMs": 0.078,
    +  "slaViolations10ms": 0
    +}
    +                    
    +
    + +

    + Same workload, same allocation rate. G1: 20 SLA violations | C4: Zero violations

    @@ -678,6 +838,102 @@

    C4 Behavior

    + +
    +

    Burst Pattern Simulation

    +

    Realistic Market Conditions

    +
    +

    The publisher simulates a 60-second market cycle:

    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    TimeEventRate Multiplier
    0-20sNormal trading1x
    20-25sNews event spike5x burst
    25-45sNormal trading1x
    45-50sMarket close rush3x spike
    50-60sNormal trading1x
    +

    + Tests GC response to variable load. G1 may trigger mixed collections during bursts; C4 adapts smoothly. +

    +
    + + +
    +

    SLA Violation Tracking

    +

    Real-Time Compliance Monitoring

    +
    +
    +

    Tracked Thresholds

    +
      +
    • < 10ms - Within SLA
    • +
    • > 10ms - Minor violation
    • +
    • > 50ms - Major violation
    • +
    • > 100ms - Critical violation
    • +
    +
    +
    +

    Metrics Collected

    +
      +
    • P50, P95, P99, P999 latency
    • +
    • All-time maximum pause
    • +
    • Total pause count and time
    • +
    • Violation counts by threshold
    • +
    +
    +
    +
    +
    +

    G1GC SLA Stats

    +
    
    +{
    +  "totalPauseCount": 164,
    +  "totalPauseTimeMs": 799,
    +  "violationsOver10ms": 20,
    +  "violationsOver50ms": 0,
    +  "violationsOver100ms": 0
    +}
    +                    
    +
    +
    +

    C4 SLA Stats

    +
    
    +{
    +  "totalPauseCount": 665,
    +  "totalPauseTimeMs": 52,
    +  "violationsOver10ms": 0,
    +  "violationsOver50ms": 0,
    +  "violationsOver100ms": 0
    +}
    +                    
    +
    +
    +
    +

    Business Impact

    @@ -708,7 +964,24 @@

    Cost of a 100ms Pause

    - + +
    +

    Part 4

    +

    Demo & Deployment

    +

    Running and monitoring the application

    +
    + + +
    +

    Live Dashboard

    +

    Real-Time GC Visualization

    + TradeStreamEE Dashboard +

    + WebSocket-connected dashboard showing live message rates, GC pause percentiles, and SLA violations. +

    +
    + +

    Deployment Options

    Running TradeStreamEE

    @@ -742,6 +1015,32 @@

    Comparison Stack

    + +
    +

    Comparison Stack Architecture

    +

    Side-by-Side C4 vs G1 Testing

    +
    
    +┌─────────────────────────┐    ┌─────────────────────────┐
    +│   C4 Cluster (Prime)    │    │   G1 Cluster (Temurin)  │
    +│   └─ Traefik :8080      │    │   └─ Traefik :9080      │
    +│      ├─ instance-1      │    │      ├─ instance-1      │
    +│      ├─ instance-2      │    │      ├─ instance-2      │
    +│      └─ instance-3      │    │      └─ instance-3      │
    +│   JMX: 9010-9012        │    │   JMX: 9020-9022        │
    +└───────────┬─────────────┘    └───────────┬─────────────┘
    +            │                              │
    +            └──────────┬───────────────────┘
    +                       ▼
    +              ┌────────────────┐
    +              │   Prometheus   │──▶ Grafana :3000
    +              │    :9090       │
    +              └────────────────┘
    +            
    +

    + Both clusters run identical workloads. JMX Exporter exposes GC metrics to Prometheus. +

    +
    +

    Stress Testing API

    @@ -777,33 +1076,33 @@

    Technology Stack

    Runtime - Azul Platform Prime - C4 pauseless garbage collection + Azul Platform Prime 21 + C4/GPGC pauseless garbage collection App Server - Payara Micro 7 + Payara Micro 7.2025.2 Jakarta EE 11 container Transport - Aeron IPC - Shared memory messaging + Aeron 1.46.7 + Shared memory IPC messaging Encoding - SBE + SBE 1.34.0 Zero-copy binary serialization Clustering - Hazelcast - Distributed state and discovery + Hazelcast 5.5.0 + Distributed topics and CP subsystem Monitoring Prometheus + Grafana - Metrics and visualization + JMX metrics and visualization
    @@ -813,11 +1112,11 @@

    Technology Stack

    Key Takeaways

    1. G1's stop-the-world pauses are architectural - no tuning eliminates them
    2. -
    3. C4 collects concurrently - application never stops
    4. +
    5. C4/GPGC collects concurrently - application never stops
    6. Pause time in G1 scales with live set - larger heaps = longer pauses
    7. C4 pause time is constant - regardless of heap or live set size
    8. Remembered sets add overhead in G1 - cross-gen refs trigger write barriers
    9. -
    10. C4 has no generations - no remembered sets, no barrier overhead
    11. +
    12. C4 collects generations concurrently - no STW for generational collection
    13. Scenario-based testing exposes weaknesses - targeted stress reveals true behavior
    14. Enterprise Java can achieve HFT latency - with the right runtime
    @@ -829,12 +1128,12 @@

    Resources

    TradeStreamEE Features

      -
    • Scenario-based GC stress testing (5 scenarios)
    • +
    • Scenario-based GC stress testing (6 allocation modes)
    • Real-time pause time visualization
    • -
    • Side-by-side C4 vs G1 comparison
    • -
    • Zero-copy message pipeline (Aeron + SBE)
    • -
    • Hazelcast clustering support
    • -
    • Prometheus/Grafana monitoring
    • +
    • Side-by-side C4 vs G1 comparison clusters
    • +
    • Zero-copy message pipeline (Aeron 1.46.7 + SBE 1.34.0)
    • +
    • Hazelcast 5.5 clustering with reliable topics
    • +
    • Prometheus/Grafana monitoring stack

    Learn More

    @@ -846,6 +1145,35 @@

    Learn More

    + +
    +

    Lessons Learned

    +

    Practical Insights from Implementation

    +
    +
    +

    What Worked

    +
      +
    • SBE flyweight pattern eliminates allocation
    • +
    • Aeron IPC provides true zero-copy
    • +
    • C4 requires no tuning for consistent pauses
    • +
    • Scenario-based testing reveals real behavior
    • +
    +
    +
    +

    Watch Out For

    +
      +
    • GPGC reports as collector name, not "C4"
    • +
    • WebSocket JSON conversion adds latency
    • +
    • 1-in-50 sampling needed for UI stability
    • +
    • Docker shared memory sizing for Aeron
    • +
    +
    +
    +
    +

    Key Insight: The application code stays simple. The GC choice handles the complexity of memory management without code changes.

    +
    +
    +
    @@ -866,6 +1194,7 @@

    Questions?

    Reveal.initialize({ hash: true, slideNumber: true, + loop: true, transition: 'slide', backgroundTransition: 'fade', plugins: [RevealMarkdown, RevealHighlight, RevealNotes], diff --git a/src/test/java/fish/payara/trader/BasicFunctionalityTest.java b/src/test/java/fish/payara/trader/BasicFunctionalityTest.java index afa5100..3e97c9c 100644 --- a/src/test/java/fish/payara/trader/BasicFunctionalityTest.java +++ b/src/test/java/fish/payara/trader/BasicFunctionalityTest.java @@ -12,75 +12,73 @@ @DisplayName("Basic Functionality Tests") class BasicFunctionalityTest { - @Test - @DisplayName("Should verify allocation modes work correctly") - void shouldVerifyAllocationModesWorkCorrectly() { - // Test AllocationMode enum - assertEquals(AllocationMode.OFF, AllocationMode.valueOf("OFF")); - assertEquals(AllocationMode.STEADY_LOAD, AllocationMode.valueOf("STEADY_LOAD")); - assertEquals(AllocationMode.GROWING_HEAP, AllocationMode.valueOf("GROWING_HEAP")); - assertEquals(AllocationMode.PROMOTION_STORM, AllocationMode.valueOf("PROMOTION_STORM")); - assertEquals(AllocationMode.FRAGMENTATION, AllocationMode.valueOf("FRAGMENTATION")); + @Test + @DisplayName("Should verify allocation modes work correctly") + void shouldVerifyAllocationModesWorkCorrectly() { + // Test AllocationMode enum + assertEquals(AllocationMode.OFF, AllocationMode.valueOf("OFF")); + assertEquals(AllocationMode.STEADY_LOAD, AllocationMode.valueOf("STEADY_LOAD")); + assertEquals(AllocationMode.GROWING_HEAP, AllocationMode.valueOf("GROWING_HEAP")); + assertEquals(AllocationMode.PROMOTION_STORM, AllocationMode.valueOf("PROMOTION_STORM")); + assertEquals(AllocationMode.FRAGMENTATION, AllocationMode.valueOf("FRAGMENTATION")); - // Test allocation rates - assertEquals(0, AllocationMode.OFF.getAllocationRateMBPerSec()); - assertTrue(AllocationMode.STEADY_LOAD.getAllocationRateMBPerSec() > 0); - // Note: STEADY_LOAD rate (200) is > OFF (0) - } + // Test allocation rates + assertEquals(0, AllocationMode.OFF.getAllocationRateMBPerSec()); + assertTrue(AllocationMode.STEADY_LOAD.getAllocationRateMBPerSec() > 0); + // Note: STEADY_LOAD rate (200) is > OFF (0) + } - @Test - @DisplayName("Should initialize memory pressure service without CDI") - void shouldInitializeMemoryPressureServiceWithoutCDI() { - MemoryPressureService service = new MemoryPressureService(); - service.init(); + @Test + @DisplayName("Should initialize memory pressure service without CDI") + void shouldInitializeMemoryPressureServiceWithoutCDI() { + MemoryPressureService service = new MemoryPressureService(); + service.init(); - // Can't test mode changes without CDI injection, but can verify initialization - assertNotNull(service); - assertEquals(AllocationMode.OFF, service.getCurrentMode()); // Default should be OFF - } + // Can't test mode changes without CDI injection, but can verify initialization + assertNotNull(service); + assertEquals(AllocationMode.OFF, service.getCurrentMode()); // Default should be OFF + } - @Test - @DisplayName("Should capture and calculate GC statistics") - void shouldCaptureAndCalculateGCStatistics() { - // Test GC utility functionality - GCTestUtil.GCStatistics beforeStats = GCTestUtil.captureInitialStats(); - assertNotNull(beforeStats); - assertTrue(beforeStats.collections >= 0); - assertTrue(beforeStats.time >= 0); + @Test + @DisplayName("Should capture and calculate GC statistics") + void shouldCaptureAndCalculateGCStatistics() { + // Test GC utility functionality + GCTestUtil.GCStatistics beforeStats = GCTestUtil.captureInitialStats(); + assertNotNull(beforeStats); + assertTrue(beforeStats.collections >= 0); + assertTrue(beforeStats.time >= 0); - // Create some memory pressure - GCTestUtil.allocateMemory(10); // 10MB + // Create some memory pressure + GCTestUtil.allocateMemory(10); // 10MB - GCTestUtil.GCStatistics afterStats = GCTestUtil.calculateDelta(beforeStats); - assertNotNull(afterStats); - assertTrue(afterStats.collections >= 0); - assertTrue(afterStats.time >= 0); - } + GCTestUtil.GCStatistics afterStats = GCTestUtil.calculateDelta(beforeStats); + assertNotNull(afterStats); + assertTrue(afterStats.collections >= 0); + assertTrue(afterStats.time >= 0); + } - @Test - @DisplayName("Should measure operation time correctly") - void shouldMeasureOperationTimeCorrectly() { - long startTime = System.nanoTime(); + @Test + @DisplayName("Should measure operation time correctly") + void shouldMeasureOperationTimeCorrectly() { + long startTime = System.nanoTime(); - // Simulate some work - try { - Thread.sleep(10); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } + // Simulate some work + try { + Thread.sleep(10); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } - long pauseTime = - GCTestUtil.measurePauseTime( - () -> { - // Simulate GC pause - try { + long pauseTime = GCTestUtil.measurePauseTime(() -> { + // Simulate GC pause + try { Thread.sleep(5); - } catch (InterruptedException e) { + } catch (InterruptedException e) { Thread.currentThread().interrupt(); - } - }); + } + }); - assertTrue(pauseTime >= 0, "Pause time should be non-negative"); - // Note: This might not measure actual GC pause time in all cases, but tests the utility - } + assertTrue(pauseTime >= 0, "Pause time should be non-negative"); + // Note: This might not measure actual GC pause time in all cases, but tests the utility + } } diff --git a/src/test/java/fish/payara/trader/aeron/MarketDataFragmentHandlerTest.java b/src/test/java/fish/payara/trader/aeron/MarketDataFragmentHandlerTest.java index 90aa3a3..192190a 100644 --- a/src/test/java/fish/payara/trader/aeron/MarketDataFragmentHandlerTest.java +++ b/src/test/java/fish/payara/trader/aeron/MarketDataFragmentHandlerTest.java @@ -18,164 +18,166 @@ @ExtendWith(MockitoExtension.class) class MarketDataFragmentHandlerTest { - private MarketDataFragmentHandler handler; + private MarketDataFragmentHandler handler; - @Mock private MarketDataBroadcaster broadcaster; + @Mock + private MarketDataBroadcaster broadcaster; - @Mock private Header header; + @Mock + private Header header; - @BeforeEach - void setUp() { - handler = new MarketDataFragmentHandler(); - handler.broadcaster = broadcaster; - } - - @Test - void testProcessTradeMessage() { - ByteBuffer byteBuffer = ByteBuffer.allocate(256); - UnsafeBuffer buffer = new UnsafeBuffer(byteBuffer); - - MessageHeaderEncoder headerEncoder = new MessageHeaderEncoder(); - TradeEncoder tradeEncoder = new TradeEncoder(); - - // Send 50 messages to ensure sampling triggers - for (int i = 0; i < 50; i++) { - tradeEncoder.wrapAndApplyHeader(buffer, 0, headerEncoder); - tradeEncoder.timestamp(System.currentTimeMillis()); - tradeEncoder.symbol("AAPL"); - tradeEncoder.price(15000); - tradeEncoder.quantity(100); - - int encodedLength = headerEncoder.encodedLength() + tradeEncoder.encodedLength(); - handler.onFragment(buffer, 0, encodedLength, header); + @BeforeEach + void setUp() { + handler = new MarketDataFragmentHandler(); + handler.broadcaster = broadcaster; } - ArgumentCaptor messageCaptor = ArgumentCaptor.forClass(String.class); - verify(broadcaster, atLeastOnce()).broadcast(messageCaptor.capture()); - - String broadcastedMessage = messageCaptor.getValue(); - assertThat(broadcastedMessage).contains("\"type\":\"trade\""); - assertThat(broadcastedMessage).contains("\"symbol\":\"AAPL\""); - assertThat(broadcastedMessage).contains("\"price\":1.5"); - assertThat(broadcastedMessage).contains("\"quantity\":100"); - } - - @Test - void testProcessQuoteMessage() { - ByteBuffer byteBuffer = ByteBuffer.allocate(256); - UnsafeBuffer buffer = new UnsafeBuffer(byteBuffer); - - MessageHeaderEncoder headerEncoder = new MessageHeaderEncoder(); - QuoteEncoder quoteEncoder = new QuoteEncoder(); - - // Send 50 messages to ensure sampling triggers - for (int i = 0; i < 50; i++) { - quoteEncoder.wrapAndApplyHeader(buffer, 0, headerEncoder); - quoteEncoder.timestamp(System.currentTimeMillis()); - quoteEncoder.symbol("MSFT"); - quoteEncoder.bidPrice(30000); - quoteEncoder.askPrice(30100); - quoteEncoder.bidSize(200); - quoteEncoder.askSize(150); - - int encodedLength = headerEncoder.encodedLength() + quoteEncoder.encodedLength(); - handler.onFragment(buffer, 0, encodedLength, header); + @Test + void testProcessTradeMessage() { + ByteBuffer byteBuffer = ByteBuffer.allocate(256); + UnsafeBuffer buffer = new UnsafeBuffer(byteBuffer); + + MessageHeaderEncoder headerEncoder = new MessageHeaderEncoder(); + TradeEncoder tradeEncoder = new TradeEncoder(); + + // Send 50 messages to ensure sampling triggers + for (int i = 0; i < 50; i++) { + tradeEncoder.wrapAndApplyHeader(buffer, 0, headerEncoder); + tradeEncoder.timestamp(System.currentTimeMillis()); + tradeEncoder.symbol("AAPL"); + tradeEncoder.price(15000); + tradeEncoder.quantity(100); + + int encodedLength = headerEncoder.encodedLength() + tradeEncoder.encodedLength(); + handler.onFragment(buffer, 0, encodedLength, header); + } + + ArgumentCaptor messageCaptor = ArgumentCaptor.forClass(String.class); + verify(broadcaster, atLeastOnce()).broadcast(messageCaptor.capture()); + + String broadcastedMessage = messageCaptor.getValue(); + assertThat(broadcastedMessage).contains("\"type\":\"trade\""); + assertThat(broadcastedMessage).contains("\"symbol\":\"AAPL\""); + assertThat(broadcastedMessage).contains("\"price\":1.5"); + assertThat(broadcastedMessage).contains("\"quantity\":100"); } - ArgumentCaptor messageCaptor = ArgumentCaptor.forClass(String.class); - verify(broadcaster, atLeastOnce()).broadcast(messageCaptor.capture()); + @Test + void testProcessQuoteMessage() { + ByteBuffer byteBuffer = ByteBuffer.allocate(256); + UnsafeBuffer buffer = new UnsafeBuffer(byteBuffer); + + MessageHeaderEncoder headerEncoder = new MessageHeaderEncoder(); + QuoteEncoder quoteEncoder = new QuoteEncoder(); + + // Send 50 messages to ensure sampling triggers + for (int i = 0; i < 50; i++) { + quoteEncoder.wrapAndApplyHeader(buffer, 0, headerEncoder); + quoteEncoder.timestamp(System.currentTimeMillis()); + quoteEncoder.symbol("MSFT"); + quoteEncoder.bidPrice(30000); + quoteEncoder.askPrice(30100); + quoteEncoder.bidSize(200); + quoteEncoder.askSize(150); + + int encodedLength = headerEncoder.encodedLength() + quoteEncoder.encodedLength(); + handler.onFragment(buffer, 0, encodedLength, header); + } + + ArgumentCaptor messageCaptor = ArgumentCaptor.forClass(String.class); + verify(broadcaster, atLeastOnce()).broadcast(messageCaptor.capture()); + + String broadcastedMessage = messageCaptor.getValue(); + assertThat(broadcastedMessage).contains("\"type\":\"quote\""); + assertThat(broadcastedMessage).contains("\"symbol\":\"MSFT\""); + assertThat(broadcastedMessage).contains("\"bid\":{\"price\":3.0"); + assertThat(broadcastedMessage).contains("\"ask\":{\"price\":3.01"); + } - String broadcastedMessage = messageCaptor.getValue(); - assertThat(broadcastedMessage).contains("\"type\":\"quote\""); - assertThat(broadcastedMessage).contains("\"symbol\":\"MSFT\""); - assertThat(broadcastedMessage).contains("\"bid\":{\"price\":3.0"); - assertThat(broadcastedMessage).contains("\"ask\":{\"price\":3.01"); - } + @Test + void testProcessHeartbeatMessage() { + ByteBuffer byteBuffer = ByteBuffer.allocate(256); + UnsafeBuffer buffer = new UnsafeBuffer(byteBuffer); - @Test - void testProcessHeartbeatMessage() { - ByteBuffer byteBuffer = ByteBuffer.allocate(256); - UnsafeBuffer buffer = new UnsafeBuffer(byteBuffer); + MessageHeaderEncoder headerEncoder = new MessageHeaderEncoder(); + HeartbeatEncoder heartbeatEncoder = new HeartbeatEncoder(); - MessageHeaderEncoder headerEncoder = new MessageHeaderEncoder(); - HeartbeatEncoder heartbeatEncoder = new HeartbeatEncoder(); + heartbeatEncoder.wrapAndApplyHeader(buffer, 0, headerEncoder); + heartbeatEncoder.timestamp(System.currentTimeMillis()); - heartbeatEncoder.wrapAndApplyHeader(buffer, 0, headerEncoder); - heartbeatEncoder.timestamp(System.currentTimeMillis()); + int encodedLength = headerEncoder.encodedLength() + heartbeatEncoder.encodedLength(); - int encodedLength = headerEncoder.encodedLength() + heartbeatEncoder.encodedLength(); + handler.onFragment(buffer, 0, encodedLength, header); - handler.onFragment(buffer, 0, encodedLength, header); + verify(broadcaster, never()).broadcast(any()); + } - verify(broadcaster, never()).broadcast(any()); - } + @Test + void testMessageSampling() { + ByteBuffer byteBuffer = ByteBuffer.allocate(256); + UnsafeBuffer buffer = new UnsafeBuffer(byteBuffer); - @Test - void testMessageSampling() { - ByteBuffer byteBuffer = ByteBuffer.allocate(256); - UnsafeBuffer buffer = new UnsafeBuffer(byteBuffer); + MessageHeaderEncoder headerEncoder = new MessageHeaderEncoder(); + TradeEncoder tradeEncoder = new TradeEncoder(); - MessageHeaderEncoder headerEncoder = new MessageHeaderEncoder(); - TradeEncoder tradeEncoder = new TradeEncoder(); + for (int i = 0; i < 100; i++) { + tradeEncoder.wrapAndApplyHeader(buffer, 0, headerEncoder); + tradeEncoder.timestamp(System.currentTimeMillis()); + tradeEncoder.symbol("TEST"); + tradeEncoder.price(10000 + i); + tradeEncoder.quantity(100); - for (int i = 0; i < 100; i++) { - tradeEncoder.wrapAndApplyHeader(buffer, 0, headerEncoder); - tradeEncoder.timestamp(System.currentTimeMillis()); - tradeEncoder.symbol("TEST"); - tradeEncoder.price(10000 + i); - tradeEncoder.quantity(100); + int encodedLength = headerEncoder.encodedLength() + tradeEncoder.encodedLength(); + handler.onFragment(buffer, 0, encodedLength, header); + } - int encodedLength = headerEncoder.encodedLength() + tradeEncoder.encodedLength(); - handler.onFragment(buffer, 0, encodedLength, header); + ArgumentCaptor messageCaptor = ArgumentCaptor.forClass(String.class); + verify(broadcaster, times(2)).broadcast(messageCaptor.capture()); } - ArgumentCaptor messageCaptor = ArgumentCaptor.forClass(String.class); - verify(broadcaster, times(2)).broadcast(messageCaptor.capture()); - } + @Test + void testInvalidTemplateId() { + ByteBuffer byteBuffer = ByteBuffer.allocate(256); + UnsafeBuffer buffer = new UnsafeBuffer(byteBuffer); - @Test - void testInvalidTemplateId() { - ByteBuffer byteBuffer = ByteBuffer.allocate(256); - UnsafeBuffer buffer = new UnsafeBuffer(byteBuffer); + MessageHeaderEncoder headerEncoder = new MessageHeaderEncoder(); + headerEncoder.wrap(buffer, 0); + headerEncoder.blockLength(0); + headerEncoder.templateId(9999); + headerEncoder.schemaId(1); + headerEncoder.version(0); - MessageHeaderEncoder headerEncoder = new MessageHeaderEncoder(); - headerEncoder.wrap(buffer, 0); - headerEncoder.blockLength(0); - headerEncoder.templateId(9999); - headerEncoder.schemaId(1); - headerEncoder.version(0); + int encodedLength = headerEncoder.encodedLength(); - int encodedLength = headerEncoder.encodedLength(); + handler.onFragment(buffer, 0, encodedLength, header); - handler.onFragment(buffer, 0, encodedLength, header); + verify(broadcaster, never()).broadcast(any()); + } - verify(broadcaster, never()).broadcast(any()); - } + @Test + void testPriceConversion() { + ByteBuffer byteBuffer = ByteBuffer.allocate(256); + UnsafeBuffer buffer = new UnsafeBuffer(byteBuffer); - @Test - void testPriceConversion() { - ByteBuffer byteBuffer = ByteBuffer.allocate(256); - UnsafeBuffer buffer = new UnsafeBuffer(byteBuffer); + MessageHeaderEncoder headerEncoder = new MessageHeaderEncoder(); + TradeEncoder tradeEncoder = new TradeEncoder(); - MessageHeaderEncoder headerEncoder = new MessageHeaderEncoder(); - TradeEncoder tradeEncoder = new TradeEncoder(); + // Send 50 messages to ensure sampling triggers + for (int i = 0; i < 50; i++) { + tradeEncoder.wrapAndApplyHeader(buffer, 0, headerEncoder); + tradeEncoder.timestamp(System.currentTimeMillis()); + tradeEncoder.symbol("XYZ"); + tradeEncoder.price(123456); + tradeEncoder.quantity(1); - // Send 50 messages to ensure sampling triggers - for (int i = 0; i < 50; i++) { - tradeEncoder.wrapAndApplyHeader(buffer, 0, headerEncoder); - tradeEncoder.timestamp(System.currentTimeMillis()); - tradeEncoder.symbol("XYZ"); - tradeEncoder.price(123456); - tradeEncoder.quantity(1); + int encodedLength = headerEncoder.encodedLength() + tradeEncoder.encodedLength(); + handler.onFragment(buffer, 0, encodedLength, header); + } - int encodedLength = headerEncoder.encodedLength() + tradeEncoder.encodedLength(); - handler.onFragment(buffer, 0, encodedLength, header); - } + ArgumentCaptor messageCaptor = ArgumentCaptor.forClass(String.class); + verify(broadcaster, atLeastOnce()).broadcast(messageCaptor.capture()); - ArgumentCaptor messageCaptor = ArgumentCaptor.forClass(String.class); - verify(broadcaster, atLeastOnce()).broadcast(messageCaptor.capture()); - - String broadcastedMessage = messageCaptor.getValue(); - assertThat(broadcastedMessage).contains("\"price\":12.3456"); - } + String broadcastedMessage = messageCaptor.getValue(); + assertThat(broadcastedMessage).contains("\"price\":12.3456"); + } } diff --git a/src/test/java/fish/payara/trader/concurrency/ConcurrencyConfigTest.java b/src/test/java/fish/payara/trader/concurrency/ConcurrencyConfigTest.java index 4fa300b..e07b89b 100644 --- a/src/test/java/fish/payara/trader/concurrency/ConcurrencyConfigTest.java +++ b/src/test/java/fish/payara/trader/concurrency/ConcurrencyConfigTest.java @@ -11,302 +11,249 @@ @DisplayName("ConcurrencyConfig Tests") class ConcurrencyConfigTest { - @Test - @DisplayName("Should have ApplicationScoped annotation") - void shouldHaveApplicationScopedAnnotation() { - // Act - ApplicationScoped annotation = ConcurrencyConfig.class.getAnnotation(ApplicationScoped.class); - - // Assert - assertNotNull(annotation, "ApplicationScoped annotation should be present"); - } - - @Test - @DisplayName("Should have ManagedExecutorDefinition annotation") - void shouldHaveManagedExecutorDefinitionAnnotation() { - // Act - ManagedExecutorDefinition annotation = - ConcurrencyConfig.class.getAnnotation(ManagedExecutorDefinition.class); - - // Assert - assertNotNull(annotation, "ManagedExecutorDefinition annotation should be present"); - } - - @Test - @DisplayName("Should have correct executor definition properties") - void shouldHaveCorrectExecutorDefinitionProperties() { - // Act - ManagedExecutorDefinition annotation = - ConcurrencyConfig.class.getAnnotation(ManagedExecutorDefinition.class); - - // Assert - assertNotNull(annotation, "Annotation should be present"); - - // Check executor name - assertEquals( - "java:module/concurrent/VirtualThreadExecutor", - annotation.name(), - "Executor name should match expected value"); - - // Check virtual threads enabled - assertTrue(annotation.virtual(), "Virtual threads should be enabled"); - - // Check qualifiers - assertEquals(1, annotation.qualifiers().length, "Should have exactly one qualifier"); - assertEquals( - VirtualThreadExecutor.class, - annotation.qualifiers()[0], - "Should use VirtualThreadExecutor qualifier"); - } - - @Test - @DisplayName("Should be a concrete class") - void shouldBeAConcreteClass() { - // Assert - assertFalse( - ConcurrencyConfig.class.isInterface(), "ConcurrencyConfig should be a concrete class"); - assertFalse( - java.lang.reflect.Modifier.isAbstract(ConcurrencyConfig.class.getModifiers()), - "ConcurrencyConfig should not be abstract"); - assertTrue( - ConcurrencyConfig.class.isSynthetic() == false, - "ConcurrencyConfig should not be synthetic"); - } - - @Test - @DisplayName("Should be in correct package") - void shouldBeInCorrectPackage() { - // Assert - assertEquals( - "fish.payara.trader.concurrency", - ConcurrencyConfig.class.getPackage().getName(), - "Should be in fish.payara.trader.concurrency package"); - } - - @Test - @DisplayName("Should have correct simple name") - void shouldHaveCorrectSimpleName() { - // Assert - assertEquals( - "ConcurrencyConfig", - ConcurrencyConfig.class.getSimpleName(), - "Should have correct simple name"); - } - - @Test - @DisplayName("Should have public default constructor") - void shouldHavePublicDefaultConstructor() { - // Act - java.lang.reflect.Constructor[] constructors = ConcurrencyConfig.class.getConstructors(); - - // Assert - assertTrue(constructors.length > 0, "Should have at least one constructor"); - - // Should have a public no-args constructor - boolean hasPublicNoArgsConstructor = false; - for (java.lang.reflect.Constructor constructor : constructors) { - if (constructor.getParameterCount() == 0 - && java.lang.reflect.Modifier.isPublic(constructor.getModifiers())) { - hasPublicNoArgsConstructor = true; - break; - } + @Test + @DisplayName("Should have ApplicationScoped annotation") + void shouldHaveApplicationScopedAnnotation() { + // Act + ApplicationScoped annotation = ConcurrencyConfig.class.getAnnotation(ApplicationScoped.class); + + // Assert + assertNotNull(annotation, "ApplicationScoped annotation should be present"); } - assertTrue(hasPublicNoArgsConstructor, "Should have a public no-args constructor"); - } - - @Test - @DisplayName("Should create instances successfully") - void shouldCreateInstancesSuccessfully() { - // Act & Assert - assertDoesNotThrow( - () -> { - ConcurrencyConfig config = new ConcurrencyConfig(); - assertNotNull(config, "Should create instance without throwing exception"); - }, - "Should be able to create instance"); - } - - @Test - @DisplayName("Should not implement any interfaces") - void shouldNotImplementAnyInterfaces() { - // Act - Class[] interfaces = ConcurrencyConfig.class.getInterfaces(); - - // Assert - assertEquals(0, interfaces.length, "Should not implement any interfaces"); - } - - @Test - @DisplayName("Should have correct inheritance hierarchy") - void shouldHaveCorrectInheritanceHierarchy() { - // Act - Class superClass = ConcurrencyConfig.class.getSuperclass(); - - // Assert - assertEquals(Object.class, superClass, "Should extend Object class directly"); - } - - @Test - @DisplayName("Should be annotated with proper lifecycle scope") - void shouldBeAnnotatedWithProperLifecycleScope() { - // Act - ApplicationScoped applicationScoped = - ConcurrencyConfig.class.getAnnotation(ApplicationScoped.class); - - // Assert - assertNotNull(applicationScoped, "Should have ApplicationScoped annotation"); - assertEquals( - "jakarta.enterprise.context.ApplicationScoped", - applicationScoped.annotationType().getName(), - "Should use Jakarta EE ApplicationScoped"); - } - - @Test - @DisplayName("Should configure virtual threads for executor") - void shouldConfigureVirtualThreadsForExecutor() { - // Act - ManagedExecutorDefinition annotation = - ConcurrencyConfig.class.getAnnotation(ManagedExecutorDefinition.class); - - // Assert - assertNotNull(annotation, "Should have ManagedExecutorDefinition"); - assertTrue(annotation.virtual(), "Should enable virtual threads"); - assertEquals( - "java:module/concurrent/VirtualThreadExecutor", - annotation.name(), - "Should use specific jndi name for virtual thread executor"); - } - - @Test - @DisplayName("Should have VirtualThreadExecutor qualifier") - void shouldHaveVirtualThreadExecutorQualifier() { - // Act - ManagedExecutorDefinition annotation = - ConcurrencyConfig.class.getAnnotation(ManagedExecutorDefinition.class); - - // Assert - assertNotNull(annotation.qualifiers(), "Qualifiers array should not be null"); - assertEquals(1, annotation.qualifiers().length, "Should have exactly one qualifier"); - assertEquals( - VirtualThreadExecutor.class, - annotation.qualifiers()[0], - "Should use VirtualThreadExecutor as qualifier"); - } - - @Test - @DisplayName("Should have correct JNDI name pattern") - void shouldHaveCorrectJNDINamePattern() { - // Act - ManagedExecutorDefinition annotation = - ConcurrencyConfig.class.getAnnotation(ManagedExecutorDefinition.class); - String jndiName = annotation.name(); - - // Assert - assertNotNull(jndiName, "JNDI name should not be null"); - assertEquals( - "java:module/concurrent/VirtualThreadExecutor", - jndiName, - "JNDI name should follow expected pattern"); - assertTrue( - jndiName.startsWith("java:module/concurrent/"), - "JNDI name should start with expected prefix"); - assertTrue( - jndiName.endsWith("VirtualThreadExecutor"), "JNDI name should end with expected suffix"); - assertFalse(jndiName.trim().isEmpty(), "JNDI name should not be empty"); - } - - @Test - @DisplayName("Should have no declared fields") - void shouldHaveNoDeclaredFields() { - // Act - java.lang.reflect.Field[] fields = ConcurrencyConfig.class.getDeclaredFields(); - - // Assert - assertEquals(0, fields.length, "Should have no declared fields"); - } - - @Test - @DisplayName("Should have no declared methods") - void shouldHaveNoDeclaredMethods() { - // Act - java.lang.reflect.Method[] methods = ConcurrencyConfig.class.getDeclaredMethods(); - - // Assert - // Java compiler may add synthetic methods, so we check for user-declared methods - long userMethods = - java.util.Arrays.stream(methods).filter(method -> !method.isSynthetic()).count(); - assertEquals(0, userMethods, "Should have no user-declared methods"); - } - - @Test - @DisplayName("Should have exactly two annotations") - void shouldHaveExactlyTwoAnnotations() { - // Act - java.lang.annotation.Annotation[] annotations = ConcurrencyConfig.class.getAnnotations(); - - // Assert - assertEquals(2, annotations.length, "Should have exactly two annotations"); - - // Verify annotation types - boolean hasApplicationScoped = false; - boolean hasManagedExecutorDefinition = false; - - for (java.lang.annotation.Annotation annotation : annotations) { - if (annotation instanceof ApplicationScoped) { - hasApplicationScoped = true; - } else if (annotation instanceof ManagedExecutorDefinition) { - hasManagedExecutorDefinition = true; - } + @Test + @DisplayName("Should have ManagedExecutorDefinition annotation") + void shouldHaveManagedExecutorDefinitionAnnotation() { + // Act + ManagedExecutorDefinition annotation = ConcurrencyConfig.class.getAnnotation(ManagedExecutorDefinition.class); + + // Assert + assertNotNull(annotation, "ManagedExecutorDefinition annotation should be present"); + } + + @Test + @DisplayName("Should have correct executor definition properties") + void shouldHaveCorrectExecutorDefinitionProperties() { + // Act + ManagedExecutorDefinition annotation = ConcurrencyConfig.class.getAnnotation(ManagedExecutorDefinition.class); + + // Assert + assertNotNull(annotation, "Annotation should be present"); + + // Check executor name + assertEquals("java:module/concurrent/VirtualThreadExecutor", annotation.name(), "Executor name should match expected value"); + + // Check virtual threads enabled + assertTrue(annotation.virtual(), "Virtual threads should be enabled"); + + // Check qualifiers + assertEquals(1, annotation.qualifiers().length, "Should have exactly one qualifier"); + assertEquals(VirtualThreadExecutor.class, annotation.qualifiers()[0], "Should use VirtualThreadExecutor qualifier"); + } + + @Test + @DisplayName("Should be a concrete class") + void shouldBeAConcreteClass() { + // Assert + assertFalse(ConcurrencyConfig.class.isInterface(), "ConcurrencyConfig should be a concrete class"); + assertFalse(java.lang.reflect.Modifier.isAbstract(ConcurrencyConfig.class.getModifiers()), "ConcurrencyConfig should not be abstract"); + assertTrue(ConcurrencyConfig.class.isSynthetic() == false, "ConcurrencyConfig should not be synthetic"); + } + + @Test + @DisplayName("Should be in correct package") + void shouldBeInCorrectPackage() { + // Assert + assertEquals("fish.payara.trader.concurrency", ConcurrencyConfig.class.getPackage().getName(), "Should be in fish.payara.trader.concurrency package"); + } + + @Test + @DisplayName("Should have correct simple name") + void shouldHaveCorrectSimpleName() { + // Assert + assertEquals("ConcurrencyConfig", ConcurrencyConfig.class.getSimpleName(), "Should have correct simple name"); + } + + @Test + @DisplayName("Should have public default constructor") + void shouldHavePublicDefaultConstructor() { + // Act + java.lang.reflect.Constructor[] constructors = ConcurrencyConfig.class.getConstructors(); + + // Assert + assertTrue(constructors.length > 0, "Should have at least one constructor"); + + // Should have a public no-args constructor + boolean hasPublicNoArgsConstructor = false; + for (java.lang.reflect.Constructor constructor : constructors) { + if (constructor.getParameterCount() == 0 && java.lang.reflect.Modifier.isPublic(constructor.getModifiers())) { + hasPublicNoArgsConstructor = true; + break; + } + } + + assertTrue(hasPublicNoArgsConstructor, "Should have a public no-args constructor"); + } + + @Test + @DisplayName("Should create instances successfully") + void shouldCreateInstancesSuccessfully() { + // Act & Assert + assertDoesNotThrow(() -> { + ConcurrencyConfig config = new ConcurrencyConfig(); + assertNotNull(config, "Should create instance without throwing exception"); + }, "Should be able to create instance"); + } + + @Test + @DisplayName("Should not implement any interfaces") + void shouldNotImplementAnyInterfaces() { + // Act + Class[] interfaces = ConcurrencyConfig.class.getInterfaces(); + + // Assert + assertEquals(0, interfaces.length, "Should not implement any interfaces"); + } + + @Test + @DisplayName("Should have correct inheritance hierarchy") + void shouldHaveCorrectInheritanceHierarchy() { + // Act + Class superClass = ConcurrencyConfig.class.getSuperclass(); + + // Assert + assertEquals(Object.class, superClass, "Should extend Object class directly"); + } + + @Test + @DisplayName("Should be annotated with proper lifecycle scope") + void shouldBeAnnotatedWithProperLifecycleScope() { + // Act + ApplicationScoped applicationScoped = ConcurrencyConfig.class.getAnnotation(ApplicationScoped.class); + + // Assert + assertNotNull(applicationScoped, "Should have ApplicationScoped annotation"); + assertEquals("jakarta.enterprise.context.ApplicationScoped", applicationScoped.annotationType().getName(), "Should use Jakarta EE ApplicationScoped"); + } + + @Test + @DisplayName("Should configure virtual threads for executor") + void shouldConfigureVirtualThreadsForExecutor() { + // Act + ManagedExecutorDefinition annotation = ConcurrencyConfig.class.getAnnotation(ManagedExecutorDefinition.class); + + // Assert + assertNotNull(annotation, "Should have ManagedExecutorDefinition"); + assertTrue(annotation.virtual(), "Should enable virtual threads"); + assertEquals("java:module/concurrent/VirtualThreadExecutor", annotation.name(), "Should use specific jndi name for virtual thread executor"); + } + + @Test + @DisplayName("Should have VirtualThreadExecutor qualifier") + void shouldHaveVirtualThreadExecutorQualifier() { + // Act + ManagedExecutorDefinition annotation = ConcurrencyConfig.class.getAnnotation(ManagedExecutorDefinition.class); + + // Assert + assertNotNull(annotation.qualifiers(), "Qualifiers array should not be null"); + assertEquals(1, annotation.qualifiers().length, "Should have exactly one qualifier"); + assertEquals(VirtualThreadExecutor.class, annotation.qualifiers()[0], "Should use VirtualThreadExecutor as qualifier"); } - assertTrue(hasApplicationScoped, "Should have ApplicationScoped annotation"); - assertTrue(hasManagedExecutorDefinition, "Should have ManagedExecutorDefinition annotation"); - } - - @Test - @DisplayName("Should be suitable for CDI bean registration") - void shouldBeSuitableForCDIBeanRegistration() { - // Act & Assert - This test verifies that the class has the right properties - // for CDI bean registration - - // Should have ApplicationScoped annotation - ApplicationScoped applicationScoped = - ConcurrencyConfig.class.getAnnotation(ApplicationScoped.class); - assertNotNull(applicationScoped, "Should have ApplicationScoped for CDI"); - - // Should have ManagedExecutorDefinition for executor configuration - ManagedExecutorDefinition managedExecutorDefinition = - ConcurrencyConfig.class.getAnnotation(ManagedExecutorDefinition.class); - assertNotNull(managedExecutorDefinition, "Should have ManagedExecutorDefinition for CDI"); - - // Should be a concrete class with public constructor - assertDoesNotThrow( - () -> { - new ConcurrencyConfig(); - }, - "Should be instantiable by CDI"); - } - - @Test - @DisplayName("Should be properly configured for virtual threads") - void shouldBeProperlyConfiguredForVirtualThreads() { - // Act - ManagedExecutorDefinition definition = - ConcurrencyConfig.class.getAnnotation(ManagedExecutorDefinition.class); - - // Assert - assertTrue(definition.virtual(), "Should enable virtual threads"); - assertEquals( - "java:module/concurrent/VirtualThreadExecutor", - definition.name(), - "Should have correct JNDI name for virtual threads"); - - // Verify it uses VirtualThreadExecutor qualifier - assertEquals(1, definition.qualifiers().length); - assertEquals( - VirtualThreadExecutor.class, - definition.qualifiers()[0], - "Should use VirtualThreadExecutor qualifier"); - } + @Test + @DisplayName("Should have correct JNDI name pattern") + void shouldHaveCorrectJNDINamePattern() { + // Act + ManagedExecutorDefinition annotation = ConcurrencyConfig.class.getAnnotation(ManagedExecutorDefinition.class); + String jndiName = annotation.name(); + + // Assert + assertNotNull(jndiName, "JNDI name should not be null"); + assertEquals("java:module/concurrent/VirtualThreadExecutor", jndiName, "JNDI name should follow expected pattern"); + assertTrue(jndiName.startsWith("java:module/concurrent/"), "JNDI name should start with expected prefix"); + assertTrue(jndiName.endsWith("VirtualThreadExecutor"), "JNDI name should end with expected suffix"); + assertFalse(jndiName.trim().isEmpty(), "JNDI name should not be empty"); + } + + @Test + @DisplayName("Should have no declared fields") + void shouldHaveNoDeclaredFields() { + // Act + java.lang.reflect.Field[] fields = ConcurrencyConfig.class.getDeclaredFields(); + + // Assert + assertEquals(0, fields.length, "Should have no declared fields"); + } + + @Test + @DisplayName("Should have no declared methods") + void shouldHaveNoDeclaredMethods() { + // Act + java.lang.reflect.Method[] methods = ConcurrencyConfig.class.getDeclaredMethods(); + + // Assert + // Java compiler may add synthetic methods, so we check for user-declared methods + long userMethods = java.util.Arrays.stream(methods).filter(method -> !method.isSynthetic()).count(); + assertEquals(0, userMethods, "Should have no user-declared methods"); + } + + @Test + @DisplayName("Should have exactly two annotations") + void shouldHaveExactlyTwoAnnotations() { + // Act + java.lang.annotation.Annotation[] annotations = ConcurrencyConfig.class.getAnnotations(); + + // Assert + assertEquals(2, annotations.length, "Should have exactly two annotations"); + + // Verify annotation types + boolean hasApplicationScoped = false; + boolean hasManagedExecutorDefinition = false; + + for (java.lang.annotation.Annotation annotation : annotations) { + if (annotation instanceof ApplicationScoped) { + hasApplicationScoped = true; + } else if (annotation instanceof ManagedExecutorDefinition) { + hasManagedExecutorDefinition = true; + } + } + + assertTrue(hasApplicationScoped, "Should have ApplicationScoped annotation"); + assertTrue(hasManagedExecutorDefinition, "Should have ManagedExecutorDefinition annotation"); + } + + @Test + @DisplayName("Should be suitable for CDI bean registration") + void shouldBeSuitableForCDIBeanRegistration() { + // Act & Assert - This test verifies that the class has the right properties + // for CDI bean registration + + // Should have ApplicationScoped annotation + ApplicationScoped applicationScoped = ConcurrencyConfig.class.getAnnotation(ApplicationScoped.class); + assertNotNull(applicationScoped, "Should have ApplicationScoped for CDI"); + + // Should have ManagedExecutorDefinition for executor configuration + ManagedExecutorDefinition managedExecutorDefinition = ConcurrencyConfig.class.getAnnotation(ManagedExecutorDefinition.class); + assertNotNull(managedExecutorDefinition, "Should have ManagedExecutorDefinition for CDI"); + + // Should be a concrete class with public constructor + assertDoesNotThrow(() -> { + new ConcurrencyConfig(); + }, "Should be instantiable by CDI"); + } + + @Test + @DisplayName("Should be properly configured for virtual threads") + void shouldBeProperlyConfiguredForVirtualThreads() { + // Act + ManagedExecutorDefinition definition = ConcurrencyConfig.class.getAnnotation(ManagedExecutorDefinition.class); + + // Assert + assertTrue(definition.virtual(), "Should enable virtual threads"); + assertEquals("java:module/concurrent/VirtualThreadExecutor", definition.name(), "Should have correct JNDI name for virtual threads"); + + // Verify it uses VirtualThreadExecutor qualifier + assertEquals(1, definition.qualifiers().length); + assertEquals(VirtualThreadExecutor.class, definition.qualifiers()[0], "Should use VirtualThreadExecutor qualifier"); + } } diff --git a/src/test/java/fish/payara/trader/concurrency/VirtualThreadExecutorTest.java b/src/test/java/fish/payara/trader/concurrency/VirtualThreadExecutorTest.java index 0e4ce7f..768c881 100644 --- a/src/test/java/fish/payara/trader/concurrency/VirtualThreadExecutorTest.java +++ b/src/test/java/fish/payara/trader/concurrency/VirtualThreadExecutorTest.java @@ -14,194 +14,183 @@ @DisplayName("VirtualThreadExecutor Tests") class VirtualThreadExecutorTest { - @Test - @DisplayName("Should have correct annotation properties") - void shouldHaveCorrectAnnotationProperties() { - // Act - an annotation cannot annotate itself, so we test its properties instead - Class annotationType = VirtualThreadExecutor.class; - - // Assert - assertTrue(annotationType.isAnnotation(), "VirtualThreadExecutor should be an annotation type"); - assertNotNull(annotationType, "VirtualThreadExecutor class should be present"); - } - - @Test - @DisplayName("Should have Qualifier annotation") - void shouldHaveQualifierAnnotation() { - // Act - Qualifier qualifier = VirtualThreadExecutor.class.getAnnotation(Qualifier.class); - - // Assert - assertNotNull(qualifier, "VirtualThreadExecutor should have Qualifier annotation"); - } - - @Test - @DisplayName("Should have correct target types") - void shouldHaveCorrectTargetTypes() { - // Act - Target target = VirtualThreadExecutor.class.getAnnotation(Target.class); - - // Assert - assertNotNull(target, "Target annotation should be present"); - ElementType[] elementTypes = target.value(); - - assertTrue(containsTarget(elementTypes, ElementType.METHOD), "Should target METHOD"); - assertTrue(containsTarget(elementTypes, ElementType.FIELD), "Should target FIELD"); - assertTrue(containsTarget(elementTypes, ElementType.PARAMETER), "Should target PARAMETER"); - assertTrue(containsTarget(elementTypes, ElementType.TYPE), "Should target TYPE"); - } - - @Test - @DisplayName("Should have correct retention policy") - void shouldHaveCorrectRetentionPolicy() { - // Act - Retention retention = VirtualThreadExecutor.class.getAnnotation(Retention.class); - - // Assert - assertNotNull(retention, "Retention annotation should be present"); - assertEquals( - RetentionPolicy.RUNTIME, retention.value(), "Should have RUNTIME retention policy"); - } - - @Test - @DisplayName("Should be an interface") - void shouldBeAnInterface() { - // Assert - assertTrue( - VirtualThreadExecutor.class.isInterface(), "VirtualThreadExecutor should be an interface"); - } - - @Test - @DisplayName("Should be in correct package") - void shouldBeInCorrectPackage() { - // Assert - assertEquals( - "fish.payara.trader.concurrency", - VirtualThreadExecutor.class.getPackage().getName(), - "Should be in fish.payara.trader.concurrency package"); - } - - @Test - @DisplayName("Should have correct simple name") - void shouldHaveCorrectSimpleName() { - // Assert - assertEquals( - "VirtualThreadExecutor", - VirtualThreadExecutor.class.getSimpleName(), - "Should have correct simple name"); - } - - @Test - @DisplayName("Should be an annotation interface") - void shouldBeAnAnnotationInterface() { - // Act - boolean isAnnotation = VirtualThreadExecutor.class.isAnnotation(); - - // Assert - assertTrue(isAnnotation, "VirtualThreadExecutor should be an annotation interface"); - } - - @Test - @DisplayName("Should have no declared methods") - void shouldHaveNoDeclaredMethods() { - // Act - java.lang.reflect.Method[] methods = VirtualThreadExecutor.class.getDeclaredMethods(); - - // Assert - assertEquals(0, methods.length, "Should have no declared methods"); - } - - @Test - @DisplayName("Should have no declared fields") - void shouldHaveNoDeclaredFields() { - // Act - java.lang.reflect.Field[] fields = VirtualThreadExecutor.class.getDeclaredFields(); - - // Assert - assertEquals(0, fields.length, "Should have no declared fields"); - } - - @Test - @DisplayName("Should have no declared constructors") - void shouldHaveNoDeclaredConstructors() { - // Act - java.lang.reflect.Constructor[] constructors = - VirtualThreadExecutor.class.getDeclaredConstructors(); - - // Assert - assertEquals(0, constructors.length, "Should have no declared constructors"); - } - - @Test - @DisplayName("Should have no declared annotations except standard ones") - void shouldHaveNoDeclaredAnnotationsExceptStandardOnes() { - // Act - java.lang.annotation.Annotation[] annotations = - VirtualThreadExecutor.class.getDeclaredAnnotations(); - - // Assert - // Should have exactly 3 annotations: @Target, @Retention, @Qualifier - assertEquals(3, annotations.length, "Should have exactly 3 declared annotations"); - - // Verify the types of annotations - boolean hasTarget = false; - boolean hasRetention = false; - boolean hasQualifier = false; - - for (java.lang.annotation.Annotation annotation : annotations) { - if (annotation instanceof Target) { - hasTarget = true; - } else if (annotation instanceof Retention) { - hasRetention = true; - } else if (annotation instanceof Qualifier) { - hasQualifier = true; - } + @Test + @DisplayName("Should have correct annotation properties") + void shouldHaveCorrectAnnotationProperties() { + // Act - an annotation cannot annotate itself, so we test its properties instead + Class annotationType = VirtualThreadExecutor.class; + + // Assert + assertTrue(annotationType.isAnnotation(), "VirtualThreadExecutor should be an annotation type"); + assertNotNull(annotationType, "VirtualThreadExecutor class should be present"); } - assertTrue(hasTarget, "Should have @Target annotation"); - assertTrue(hasRetention, "Should have @Retention annotation"); - assertTrue(hasQualifier, "Should have @Qualifier annotation"); - } - - @Test - @DisplayName("Should be suitable for dependency injection") - void shouldBeSuitableForDependencyInjection() { - // Act & Assert - This test verifies that the annotation has the right properties - // for use in CDI/dependency injection frameworks - - // Should be a qualifier annotation (jakarta.inject.Qualifier) - Qualifier qualifier = VirtualThreadExecutor.class.getAnnotation(Qualifier.class); - assertNotNull(qualifier, "Should be a CDI Qualifier annotation"); - - // Should be retained at runtime for injection - Retention retention = VirtualThreadExecutor.class.getAnnotation(Retention.class); - assertEquals( - RetentionPolicy.RUNTIME, retention.value(), "Should be retained at runtime for injection"); - } - - @Test - @DisplayName("Should have descriptive documentation") - void shouldHaveDescriptiveDocumentation() { - // Act - String className = VirtualThreadExecutor.class.getSimpleName(); - Package pkg = VirtualThreadExecutor.class.getPackage(); - - // Assert - assertEquals("VirtualThreadExecutor", className, "Should have descriptive name"); - assertEquals( - "fish.payara.trader.concurrency", pkg.getName(), "Should be in meaningful package"); - - // Note: We can't easily test javadoc content without additional libraries, - // but we can verify that the class is documented by its existence and naming - } - - /** Helper method to check if target array contains specific element type */ - private boolean containsTarget(ElementType[] targets, ElementType targetType) { - for (ElementType target : targets) { - if (target == targetType) { - return true; - } + @Test + @DisplayName("Should have Qualifier annotation") + void shouldHaveQualifierAnnotation() { + // Act + Qualifier qualifier = VirtualThreadExecutor.class.getAnnotation(Qualifier.class); + + // Assert + assertNotNull(qualifier, "VirtualThreadExecutor should have Qualifier annotation"); + } + + @Test + @DisplayName("Should have correct target types") + void shouldHaveCorrectTargetTypes() { + // Act + Target target = VirtualThreadExecutor.class.getAnnotation(Target.class); + + // Assert + assertNotNull(target, "Target annotation should be present"); + ElementType[] elementTypes = target.value(); + + assertTrue(containsTarget(elementTypes, ElementType.METHOD), "Should target METHOD"); + assertTrue(containsTarget(elementTypes, ElementType.FIELD), "Should target FIELD"); + assertTrue(containsTarget(elementTypes, ElementType.PARAMETER), "Should target PARAMETER"); + assertTrue(containsTarget(elementTypes, ElementType.TYPE), "Should target TYPE"); + } + + @Test + @DisplayName("Should have correct retention policy") + void shouldHaveCorrectRetentionPolicy() { + // Act + Retention retention = VirtualThreadExecutor.class.getAnnotation(Retention.class); + + // Assert + assertNotNull(retention, "Retention annotation should be present"); + assertEquals(RetentionPolicy.RUNTIME, retention.value(), "Should have RUNTIME retention policy"); + } + + @Test + @DisplayName("Should be an interface") + void shouldBeAnInterface() { + // Assert + assertTrue(VirtualThreadExecutor.class.isInterface(), "VirtualThreadExecutor should be an interface"); + } + + @Test + @DisplayName("Should be in correct package") + void shouldBeInCorrectPackage() { + // Assert + assertEquals("fish.payara.trader.concurrency", VirtualThreadExecutor.class.getPackage().getName(), + "Should be in fish.payara.trader.concurrency package"); + } + + @Test + @DisplayName("Should have correct simple name") + void shouldHaveCorrectSimpleName() { + // Assert + assertEquals("VirtualThreadExecutor", VirtualThreadExecutor.class.getSimpleName(), "Should have correct simple name"); + } + + @Test + @DisplayName("Should be an annotation interface") + void shouldBeAnAnnotationInterface() { + // Act + boolean isAnnotation = VirtualThreadExecutor.class.isAnnotation(); + + // Assert + assertTrue(isAnnotation, "VirtualThreadExecutor should be an annotation interface"); + } + + @Test + @DisplayName("Should have no declared methods") + void shouldHaveNoDeclaredMethods() { + // Act + java.lang.reflect.Method[] methods = VirtualThreadExecutor.class.getDeclaredMethods(); + + // Assert + assertEquals(0, methods.length, "Should have no declared methods"); + } + + @Test + @DisplayName("Should have no declared fields") + void shouldHaveNoDeclaredFields() { + // Act + java.lang.reflect.Field[] fields = VirtualThreadExecutor.class.getDeclaredFields(); + + // Assert + assertEquals(0, fields.length, "Should have no declared fields"); + } + + @Test + @DisplayName("Should have no declared constructors") + void shouldHaveNoDeclaredConstructors() { + // Act + java.lang.reflect.Constructor[] constructors = VirtualThreadExecutor.class.getDeclaredConstructors(); + + // Assert + assertEquals(0, constructors.length, "Should have no declared constructors"); + } + + @Test + @DisplayName("Should have no declared annotations except standard ones") + void shouldHaveNoDeclaredAnnotationsExceptStandardOnes() { + // Act + java.lang.annotation.Annotation[] annotations = VirtualThreadExecutor.class.getDeclaredAnnotations(); + + // Assert + // Should have exactly 3 annotations: @Target, @Retention, @Qualifier + assertEquals(3, annotations.length, "Should have exactly 3 declared annotations"); + + // Verify the types of annotations + boolean hasTarget = false; + boolean hasRetention = false; + boolean hasQualifier = false; + + for (java.lang.annotation.Annotation annotation : annotations) { + if (annotation instanceof Target) { + hasTarget = true; + } else if (annotation instanceof Retention) { + hasRetention = true; + } else if (annotation instanceof Qualifier) { + hasQualifier = true; + } + } + + assertTrue(hasTarget, "Should have @Target annotation"); + assertTrue(hasRetention, "Should have @Retention annotation"); + assertTrue(hasQualifier, "Should have @Qualifier annotation"); + } + + @Test + @DisplayName("Should be suitable for dependency injection") + void shouldBeSuitableForDependencyInjection() { + // Act & Assert - This test verifies that the annotation has the right properties + // for use in CDI/dependency injection frameworks + + // Should be a qualifier annotation (jakarta.inject.Qualifier) + Qualifier qualifier = VirtualThreadExecutor.class.getAnnotation(Qualifier.class); + assertNotNull(qualifier, "Should be a CDI Qualifier annotation"); + + // Should be retained at runtime for injection + Retention retention = VirtualThreadExecutor.class.getAnnotation(Retention.class); + assertEquals(RetentionPolicy.RUNTIME, retention.value(), "Should be retained at runtime for injection"); + } + + @Test + @DisplayName("Should have descriptive documentation") + void shouldHaveDescriptiveDocumentation() { + // Act + String className = VirtualThreadExecutor.class.getSimpleName(); + Package pkg = VirtualThreadExecutor.class.getPackage(); + + // Assert + assertEquals("VirtualThreadExecutor", className, "Should have descriptive name"); + assertEquals("fish.payara.trader.concurrency", pkg.getName(), "Should be in meaningful package"); + + // Note: We can't easily test javadoc content without additional libraries, + // but we can verify that the class is documented by its existence and naming + } + + /** Helper method to check if target array contains specific element type */ + private boolean containsTarget(ElementType[] targets, ElementType targetType) { + for (ElementType target : targets) { + if (target == targetType) { + return true; + } + } + return false; } - return false; - } } diff --git a/src/test/java/fish/payara/trader/gc/GCStatsServiceTest.java b/src/test/java/fish/payara/trader/gc/GCStatsServiceTest.java index 68ad242..3302047 100644 --- a/src/test/java/fish/payara/trader/gc/GCStatsServiceTest.java +++ b/src/test/java/fish/payara/trader/gc/GCStatsServiceTest.java @@ -19,123 +19,103 @@ @DisplayName("GCStatsService Tests") class GCStatsServiceTest { - private GCStatsService gcStatsService; - - @BeforeEach - void setUp() { - gcStatsService = new GCStatsService(); - } - - @Test - @DisplayName("Should handle GC notification and record pause") - void shouldHandleGCNotificationAndRecordPause() { - Notification notification = - new Notification( - GarbageCollectionNotificationInfo.GARBAGE_COLLECTION_NOTIFICATION, "test", 1L); - CompositeData compositeData = mock(CompositeData.class); - notification.setUserData(compositeData); - - GarbageCollectionNotificationInfo info = mock(GarbageCollectionNotificationInfo.class); - GcInfo gcInfo = mock(GcInfo.class); - - when(info.getGcName()).thenReturn("G1 Young Generation"); - when(info.getGcAction()).thenReturn("end of minor GC"); - when(info.getGcCause()).thenReturn("System.gc()"); - when(info.getGcInfo()).thenReturn(gcInfo); - when(gcInfo.getDuration()).thenReturn(50L); - - try (MockedStatic mockedStatic = - mockStatic(GarbageCollectionNotificationInfo.class)) { - mockedStatic - .when(() -> GarbageCollectionNotificationInfo.from(compositeData)) - .thenReturn(info); - - gcStatsService.handleNotification(notification, null); - } + private GCStatsService gcStatsService; - List stats = gcStatsService.collectGCStats(); - // We expect at least the G1 Young Generation to be present if the JVM is running G1 - // But since collectGCStats calls ManagementFactory.getGarbageCollectorMXBeans(), - // it depends on the actual JVM. - // However, we can verify the pauseHistory internal state by calling collectGCStats - // and looking for the one we just injected. - - GCStats g1Stats = - stats.stream() - .filter(s -> s.getGcName().equals("G1 Young Generation")) - .findFirst() - .orElse(null); - - if (g1Stats != null) { - assertThat(g1Stats.getLastPauseDuration()).isEqualTo(50L); - assertThat(g1Stats.getRecentPauses()).contains(50L); - } - } - - @Test - @DisplayName("Should filter GPGC concurrent cycle notifications") - void shouldFilterGPGCConcurrentCycleNotifications() { - Notification notification = - new Notification( - GarbageCollectionNotificationInfo.GARBAGE_COLLECTION_NOTIFICATION, "test", 1L); - CompositeData compositeData = mock(CompositeData.class); - notification.setUserData(compositeData); - - GarbageCollectionNotificationInfo info = mock(GarbageCollectionNotificationInfo.class); - GcInfo gcInfo = mock(GcInfo.class); - - when(info.getGcName()).thenReturn("GPGC"); // This should be filtered - when(info.getGcInfo()).thenReturn(gcInfo); - when(gcInfo.getDuration()).thenReturn(500L); - - try (MockedStatic mockedStatic = - mockStatic(GarbageCollectionNotificationInfo.class)) { - mockedStatic - .when(() -> GarbageCollectionNotificationInfo.from(compositeData)) - .thenReturn(info); - - gcStatsService.handleNotification(notification, null); + @BeforeEach + void setUp() { + gcStatsService = new GCStatsService(); } - List stats = gcStatsService.collectGCStats(); - GCStats gpgcStats = - stats.stream().filter(s -> s.getGcName().equals("GPGC")).findFirst().orElse(null); - - // GPGC should be excluded from stats - assertThat(gpgcStats).isNull(); - } - - @Test - @DisplayName("Should reset statistics") - void shouldResetStatistics() { - Notification notification = - new Notification( - GarbageCollectionNotificationInfo.GARBAGE_COLLECTION_NOTIFICATION, "test", 1L); - CompositeData compositeData = mock(CompositeData.class); - notification.setUserData(compositeData); - - GarbageCollectionNotificationInfo info = mock(GarbageCollectionNotificationInfo.class); - GcInfo gcInfo = mock(GcInfo.class); - - when(info.getGcName()).thenReturn("Test GC"); - when(info.getGcInfo()).thenReturn(gcInfo); - when(gcInfo.getDuration()).thenReturn(100L); - - try (MockedStatic mockedStatic = - mockStatic(GarbageCollectionNotificationInfo.class)) { - mockedStatic - .when(() -> GarbageCollectionNotificationInfo.from(compositeData)) - .thenReturn(info); - gcStatsService.handleNotification(notification, null); + @Test + @DisplayName("Should handle GC notification and record pause") + void shouldHandleGCNotificationAndRecordPause() { + Notification notification = new Notification(GarbageCollectionNotificationInfo.GARBAGE_COLLECTION_NOTIFICATION, "test", 1L); + CompositeData compositeData = mock(CompositeData.class); + notification.setUserData(compositeData); + + GarbageCollectionNotificationInfo info = mock(GarbageCollectionNotificationInfo.class); + GcInfo gcInfo = mock(GcInfo.class); + + when(info.getGcName()).thenReturn("G1 Young Generation"); + when(info.getGcAction()).thenReturn("end of minor GC"); + when(info.getGcCause()).thenReturn("System.gc()"); + when(info.getGcInfo()).thenReturn(gcInfo); + when(gcInfo.getDuration()).thenReturn(50L); + + try (MockedStatic mockedStatic = mockStatic(GarbageCollectionNotificationInfo.class)) { + mockedStatic.when(() -> GarbageCollectionNotificationInfo.from(compositeData)).thenReturn(info); + + gcStatsService.handleNotification(notification, null); + } + + List stats = gcStatsService.collectGCStats(); + // We expect at least the G1 Young Generation to be present if the JVM is running G1 + // But since collectGCStats calls ManagementFactory.getGarbageCollectorMXBeans(), + // it depends on the actual JVM. + // However, we can verify the pauseHistory internal state by calling collectGCStats + // and looking for the one we just injected. + + GCStats g1Stats = stats.stream().filter(s -> s.getGcName().equals("G1 Young Generation")).findFirst().orElse(null); + + if (g1Stats != null) { + assertThat(g1Stats.getLastPauseDuration()).isEqualTo(50L); + assertThat(g1Stats.getRecentPauses()).contains(50L); + } } - gcStatsService.resetStats(); + @Test + @DisplayName("Should filter GPGC concurrent cycle notifications") + void shouldFilterGPGCConcurrentCycleNotifications() { + Notification notification = new Notification(GarbageCollectionNotificationInfo.GARBAGE_COLLECTION_NOTIFICATION, "test", 1L); + CompositeData compositeData = mock(CompositeData.class); + notification.setUserData(compositeData); + + GarbageCollectionNotificationInfo info = mock(GarbageCollectionNotificationInfo.class); + GcInfo gcInfo = mock(GcInfo.class); + + when(info.getGcName()).thenReturn("GPGC"); // This should be filtered + when(info.getGcInfo()).thenReturn(gcInfo); + when(gcInfo.getDuration()).thenReturn(500L); + + try (MockedStatic mockedStatic = mockStatic(GarbageCollectionNotificationInfo.class)) { + mockedStatic.when(() -> GarbageCollectionNotificationInfo.from(compositeData)).thenReturn(info); + + gcStatsService.handleNotification(notification, null); + } + + List stats = gcStatsService.collectGCStats(); + GCStats gpgcStats = stats.stream().filter(s -> s.getGcName().equals("GPGC")).findFirst().orElse(null); + + // GPGC should be excluded from stats + assertThat(gpgcStats).isNull(); + } - // After reset, even if the bean exists in JVM, the pause history should be gone - List stats = gcStatsService.collectGCStats(); - for (GCStats s : stats) { - assertThat(s.getRecentPauses()).isEmpty(); - assertThat(s.getLastPauseDuration()).isZero(); + @Test + @DisplayName("Should reset statistics") + void shouldResetStatistics() { + Notification notification = new Notification(GarbageCollectionNotificationInfo.GARBAGE_COLLECTION_NOTIFICATION, "test", 1L); + CompositeData compositeData = mock(CompositeData.class); + notification.setUserData(compositeData); + + GarbageCollectionNotificationInfo info = mock(GarbageCollectionNotificationInfo.class); + GcInfo gcInfo = mock(GcInfo.class); + + when(info.getGcName()).thenReturn("Test GC"); + when(info.getGcInfo()).thenReturn(gcInfo); + when(gcInfo.getDuration()).thenReturn(100L); + + try (MockedStatic mockedStatic = mockStatic(GarbageCollectionNotificationInfo.class)) { + mockedStatic.when(() -> GarbageCollectionNotificationInfo.from(compositeData)).thenReturn(info); + gcStatsService.handleNotification(notification, null); + } + + gcStatsService.resetStats(); + + // After reset, even if the bean exists in JVM, the pause history should be gone + List stats = gcStatsService.collectGCStats(); + for (GCStats s : stats) { + assertThat(s.getRecentPauses()).isEmpty(); + assertThat(s.getLastPauseDuration()).isZero(); + } } - } } diff --git a/src/test/java/fish/payara/trader/gc/GCStatsTest.java b/src/test/java/fish/payara/trader/gc/GCStatsTest.java index b22e594..50dc119 100644 --- a/src/test/java/fish/payara/trader/gc/GCStatsTest.java +++ b/src/test/java/fish/payara/trader/gc/GCStatsTest.java @@ -12,548 +12,514 @@ @DisplayName("GCStats Tests") class GCStatsTest { - @Nested - @DisplayName("Constructor and Basic Properties Tests") - class ConstructorAndBasicPropertiesTests { - - @Test - @DisplayName("Should create GCStats with default constructor") - void shouldCreateGCStatsWithDefaultConstructor() { - GCStats stats = new GCStats(); - - assertNotNull(stats, "GCStats should not be null"); - assertNull(stats.getGcName(), "GC name should be null by default"); - assertEquals(0L, stats.getCollectionCount(), "Collection count should default to 0"); - assertEquals(0L, stats.getCollectionTime(), "Collection time should default to 0"); - assertEquals(0L, stats.getLastPauseDuration(), "Last pause duration should default to 0"); - assertNull(stats.getRecentPauses(), "Recent pauses should be null by default"); - assertNull(stats.getPercentiles(), "Percentiles should be null by default"); - assertEquals(0L, stats.getTotalMemory(), "Total memory should default to 0"); - assertEquals(0L, stats.getUsedMemory(), "Used memory should default to 0"); - assertEquals(0L, stats.getFreeMemory(), "Free memory should default to 0"); + @Nested + @DisplayName("Constructor and Basic Properties Tests") + class ConstructorAndBasicPropertiesTests { + + @Test + @DisplayName("Should create GCStats with default constructor") + void shouldCreateGCStatsWithDefaultConstructor() { + GCStats stats = new GCStats(); + + assertNotNull(stats, "GCStats should not be null"); + assertNull(stats.getGcName(), "GC name should be null by default"); + assertEquals(0L, stats.getCollectionCount(), "Collection count should default to 0"); + assertEquals(0L, stats.getCollectionTime(), "Collection time should default to 0"); + assertEquals(0L, stats.getLastPauseDuration(), "Last pause duration should default to 0"); + assertNull(stats.getRecentPauses(), "Recent pauses should be null by default"); + assertNull(stats.getPercentiles(), "Percentiles should be null by default"); + assertEquals(0L, stats.getTotalMemory(), "Total memory should default to 0"); + assertEquals(0L, stats.getUsedMemory(), "Used memory should default to 0"); + assertEquals(0L, stats.getFreeMemory(), "Free memory should default to 0"); + } + } + + @Nested + @DisplayName("GC Name Tests") + class GCNameTests { + + @Test + @DisplayName("Should set and get GC name correctly") + void shouldSetAndGetGCNameCorrectly() { + GCStats stats = new GCStats(); + String gcName = "G1 Young Generation"; + + stats.setGcName(gcName); + assertEquals(gcName, stats.getGcName(), "GC name should be set and retrieved correctly"); + } + + @Test + @DisplayName("Should handle null GC name") + void shouldHandleNullGCName() { + GCStats stats = new GCStats(); + + assertDoesNotThrow(() -> stats.setGcName(null), "Setting null GC name should not throw"); + assertNull(stats.getGcName(), "GC name should be null"); + } + + @Test + @DisplayName("Should handle empty GC name") + void shouldHandleEmptyGCName() { + GCStats stats = new GCStats(); + String emptyName = ""; + + stats.setGcName(emptyName); + assertEquals(emptyName, stats.getGcName(), "Empty GC name should be handled"); + } + + @Test + @DisplayName("Should handle special characters in GC name") + void shouldHandleSpecialCharactersInGCName() { + GCStats stats = new GCStats(); + String specialName = "G1/Young (Test) @#$%^&*()"; + + stats.setGcName(specialName); + assertEquals(specialName, stats.getGcName(), "Special characters should be preserved"); + } + } + + @Nested + @DisplayName("Collection Statistics Tests") + class CollectionStatisticsTests { + + @Test + @DisplayName("Should set and get collection count correctly") + void shouldSetAndGetCollectionCountCorrectly() { + GCStats stats = new GCStats(); + long collectionCount = 12345L; + + stats.setCollectionCount(collectionCount); + assertEquals(collectionCount, stats.getCollectionCount(), "Collection count should be set and retrieved correctly"); + } + + @Test + @DisplayName("Should handle zero collection count") + void shouldHandleZeroCollectionCount() { + GCStats stats = new GCStats(); + + stats.setCollectionCount(0L); + assertEquals(0L, stats.getCollectionCount(), "Zero collection count should be handled"); + } + + @Test + @DisplayName("Should handle negative collection count") + void shouldHandleNegativeCollectionCount() { + GCStats stats = new GCStats(); + long negativeCount = -100L; + + stats.setCollectionCount(negativeCount); + assertEquals(negativeCount, stats.getCollectionCount(), "Negative collection count should be handled"); + } + + @Test + @DisplayName("Should handle very large collection count") + void shouldHandleVeryLargeCollectionCount() { + GCStats stats = new GCStats(); + long largeCount = Long.MAX_VALUE; + + stats.setCollectionCount(largeCount); + assertEquals(largeCount, stats.getCollectionCount(), "Very large collection count should be handled"); + } + + @Test + @DisplayName("Should set and get collection time correctly") + void shouldSetAndGetCollectionTimeCorrectly() { + GCStats stats = new GCStats(); + long collectionTime = 98765L; + + stats.setCollectionTime(collectionTime); + assertEquals(collectionTime, stats.getCollectionTime(), "Collection time should be set and retrieved correctly"); + } + + @Test + @DisplayName("Should handle zero collection time") + void shouldHandleZeroCollectionTime() { + GCStats stats = new GCStats(); + + stats.setCollectionTime(0L); + assertEquals(0L, stats.getCollectionTime(), "Zero collection time should be handled"); + } + + @Test + @DisplayName("Should handle negative collection time") + void shouldHandleNegativeCollectionTime() { + GCStats stats = new GCStats(); + long negativeTime = -500L; + + stats.setCollectionTime(negativeTime); + assertEquals(negativeTime, stats.getCollectionTime(), "Negative collection time should be handled"); + } + } + + @Nested + @DisplayName("Pause Duration Tests") + class PauseDurationTests { + + @Test + @DisplayName("Should set and get last pause duration correctly") + void shouldSetAndGetLastPauseDurationCorrectly() { + GCStats stats = new GCStats(); + long pauseDuration = 150L; + + stats.setLastPauseDuration(pauseDuration); + assertEquals(pauseDuration, stats.getLastPauseDuration(), "Last pause duration should be set and retrieved correctly"); + } + + @Test + @DisplayName("Should handle zero pause duration") + void shouldHandleZeroPauseDuration() { + GCStats stats = new GCStats(); + + stats.setLastPauseDuration(0L); + assertEquals(0L, stats.getLastPauseDuration(), "Zero pause duration should be handled"); + } + + @Test + @DisplayName("Should handle very long pause duration") + void shouldHandleVeryLongPauseDuration() { + GCStats stats = new GCStats(); + long longPause = 30000L; // 30 seconds + + stats.setLastPauseDuration(longPause); + assertEquals(longPause, stats.getLastPauseDuration(), "Very long pause duration should be handled"); + } + + @Test + @DisplayName("Should handle microsecond-level pause duration") + void shouldHandleMicrosecondLevelPauseDuration() { + GCStats stats = new GCStats(); + long microPause = 1L; // 1 millisecond (micro in practice) + + stats.setLastPauseDuration(microPause); + assertEquals(microPause, stats.getLastPauseDuration(), "Microsecond-level pause duration should be handled"); + } + } + + @Nested + @DisplayName("Recent Pauses Tests") + class RecentPausesTests { + + @Test + @DisplayName("Should set and get recent pauses list correctly") + void shouldSetAndGetRecentPausesListCorrectly() { + GCStats stats = new GCStats(); + List pauses = Arrays.asList(10L, 25L, 15L, 30L, 12L); + + stats.setRecentPauses(pauses); + assertEquals(pauses, stats.getRecentPauses(), "Recent pauses list should be set and retrieved correctly"); + } + + @Test + @DisplayName("Should handle empty recent pauses list") + void shouldHandleEmptyRecentPausesList() { + GCStats stats = new GCStats(); + List emptyList = List.of(); + + stats.setRecentPauses(emptyList); + assertEquals(emptyList, stats.getRecentPauses(), "Empty recent pauses list should be handled"); + } + + @Test + @DisplayName("Should handle null recent pauses list") + void shouldHandleNullRecentPausesList() { + GCStats stats = new GCStats(); + + assertDoesNotThrow(() -> stats.setRecentPauses(null), "Setting null recent pauses should not throw"); + assertNull(stats.getRecentPauses(), "Recent pauses should be null"); + } + + @Test + @DisplayName("Should handle recent pauses with zero values") + void shouldHandleRecentPausesWithZeroValues() { + GCStats stats = new GCStats(); + List zeros = Arrays.asList(0L, 0L, 0L, 0L); + + stats.setRecentPauses(zeros); + assertEquals(zeros, stats.getRecentPauses(), "Zero pause values should be handled"); + } + + @Test + @DisplayName("Should handle recent pauses with mixed values") + void shouldHandleRecentPausesWithMixedValues() { + GCStats stats = new GCStats(); + List mixedValues = Arrays.asList(5L, 0L, 1000L, 1L, 500L); + + stats.setRecentPauses(mixedValues); + assertEquals(mixedValues, stats.getRecentPauses(), "Mixed pause values should be handled"); + } + } + + @Nested + @DisplayName("Percentiles Tests") + class PercentilesTests { + + @Test + @DisplayName("Should set and get percentiles correctly") + void shouldSetAndGetPercentilesCorrectly() { + GCStats stats = new GCStats(); + GCStats.PausePercentiles percentiles = new GCStats.PausePercentiles(10L, 25L, 50L, 100L, 200L); + + stats.setPercentiles(percentiles); + assertEquals(percentiles, stats.getPercentiles(), "Percentiles should be set and retrieved correctly"); + } + + @Test + @DisplayName("Should handle null percentiles") + void shouldHandleNullPercentiles() { + GCStats stats = new GCStats(); + + assertDoesNotThrow(() -> stats.setPercentiles(null), "Setting null percentiles should not throw"); + assertNull(stats.getPercentiles(), "Percentiles should be null"); + } + + @Test + @DisplayName("Should handle all zero percentile values") + void shouldHandleAllZeroPercentileValues() { + GCStats stats = new GCStats(); + GCStats.PausePercentiles zeroPercentiles = new GCStats.PausePercentiles(0L, 0L, 0L, 0L, 0L); + + stats.setPercentiles(zeroPercentiles); + assertEquals(0L, stats.getPercentiles().getP50(), "P50 should be zero"); + assertEquals(0L, stats.getPercentiles().getP95(), "P95 should be zero"); + assertEquals(0L, stats.getPercentiles().getP99(), "P99 should be zero"); + assertEquals(0L, stats.getPercentiles().getP999(), "P999 should be zero"); + assertEquals(0L, stats.getPercentiles().getMax(), "Max should be zero"); + } + + @Test + @DisplayName("Should handle percentile with constructor") + void shouldHandlePercentileWithConstructor() { + long p50 = 15L, p95 = 50L, p99 = 120L, p999 = 300L, max = 500L; + GCStats.PausePercentiles percentiles = new GCStats.PausePercentiles(p50, p95, p99, p999, max); + + assertEquals(p50, percentiles.getP50(), "P50 should match constructor value"); + assertEquals(p95, percentiles.getP95(), "P95 should match constructor value"); + assertEquals(p99, percentiles.getP99(), "P99 should match constructor value"); + assertEquals(p999, percentiles.getP999(), "P999 should match constructor value"); + assertEquals(max, percentiles.getMax(), "Max should match constructor value"); + } + + @Test + @DisplayName("Should handle percentile setters and getters") + void shouldHandlePercentileSettersAndGetters() { + GCStats.PausePercentiles percentiles = new GCStats.PausePercentiles(); + + long p50 = 25L, p95 = 75L, p99 = 150L, p999 = 400L, max = 800L; + + percentiles.setP50(p50); + percentiles.setP95(p95); + percentiles.setP99(p99); + percentiles.setP999(p999); + percentiles.setMax(max); + + assertEquals(p50, percentiles.getP50(), "P50 should be set correctly"); + assertEquals(p95, percentiles.getP95(), "P95 should be set correctly"); + assertEquals(p99, percentiles.getP99(), "P99 should be set correctly"); + assertEquals(p999, percentiles.getP999(), "P999 should be set correctly"); + assertEquals(max, percentiles.getMax(), "Max should be set correctly"); + } + } + + @Nested + @DisplayName("Memory Statistics Tests") + class MemoryStatisticsTests { + + @Test + @DisplayName("Should set and get total memory correctly") + void shouldSetAndGetTotalMemoryCorrectly() { + GCStats stats = new GCStats(); + long totalMemory = 1024L * 1024L * 1024L; // 1GB + + stats.setTotalMemory(totalMemory); + assertEquals(totalMemory, stats.getTotalMemory(), "Total memory should be set and retrieved correctly"); + } + + @Test + @DisplayName("Should set and get used memory correctly") + void shouldSetAndGetUsedMemoryCorrectly() { + GCStats stats = new GCStats(); + long usedMemory = 512L * 1024L * 1024L; // 512MB + + stats.setUsedMemory(usedMemory); + assertEquals(usedMemory, stats.getUsedMemory(), "Used memory should be set and retrieved correctly"); + } + + @Test + @DisplayName("Should set and get free memory correctly") + void shouldSetAndGetFreeMemoryCorrectly() { + GCStats stats = new GCStats(); + long freeMemory = 256L * 1024L * 1024L; // 256MB + + stats.setFreeMemory(freeMemory); + assertEquals(freeMemory, stats.getFreeMemory(), "Free memory should be set and retrieved correctly"); + } + + @Test + @DisplayName("Should maintain memory relationship: total >= used") + void shouldMaintainMemoryRelationshipTotalGTEUsed() { + GCStats stats = new GCStats(); + long total = 1000L; + long used = 750L; + + stats.setTotalMemory(total); + stats.setUsedMemory(used); + + assertTrue(stats.getTotalMemory() >= stats.getUsedMemory(), "Total memory should be greater than or equal to used memory"); + } + + @Test + @DisplayName("Should maintain memory relationship: total >= free") + void shouldMaintainMemoryRelationshipTotalGTFree() { + GCStats stats = new GCStats(); + long total = 1000L; + long free = 300L; + + stats.setTotalMemory(total); + stats.setFreeMemory(free); + + assertTrue(stats.getTotalMemory() >= stats.getFreeMemory(), "Total memory should be greater than or equal to free memory"); + } + + @Test + @DisplayName("Should maintain memory relationship: total >= used + free") + void shouldMaintainMemoryRelationshipTotalGEUsedPlusFree() { + GCStats stats = new GCStats(); + long total = 1000L; + long used = 600L; + long free = 400L; + + stats.setTotalMemory(total); + stats.setUsedMemory(used); + stats.setFreeMemory(free); + + assertTrue(stats.getTotalMemory() >= stats.getUsedMemory() + stats.getFreeMemory(), + "Total memory should be greater than or equal to used + free memory"); + } + + @Test + @DisplayName("Should handle zero memory values") + void shouldHandleZeroMemoryValues() { + GCStats stats = new GCStats(); + + stats.setTotalMemory(0L); + stats.setUsedMemory(0L); + stats.setFreeMemory(0L); + + assertEquals(0L, stats.getTotalMemory(), "Total memory should handle zero"); + assertEquals(0L, stats.getUsedMemory(), "Used memory should handle zero"); + assertEquals(0L, stats.getFreeMemory(), "Free memory should handle zero"); + } + + @Test + @DisplayName("Should handle large memory values") + void shouldHandleLargeMemoryValues() { + GCStats stats = new GCStats(); + long largeValue = Long.MAX_VALUE / 2; // Half of max long value + + stats.setTotalMemory(largeValue); + stats.setUsedMemory(largeValue / 2); + stats.setFreeMemory(largeValue / 4); + + assertEquals(largeValue, stats.getTotalMemory(), "Large total memory should be handled"); + assertEquals(largeValue / 2, stats.getUsedMemory(), "Large used memory should be handled"); + assertEquals(largeValue / 4, stats.getFreeMemory(), "Large free memory should be handled"); + } + } + + @Nested + @DisplayName("Consistency Tests") + class ConsistencyTests { + + @Test + @DisplayName("Should maintain consistent state across multiple property changes") + void shouldMaintainConsistentStateAcrossMultiplePropertyChanges() { + GCStats stats = new GCStats(); + + // Set all properties + stats.setGcName("Test GC"); + stats.setCollectionCount(100L); + stats.setCollectionTime(5000L); + stats.setLastPauseDuration(50L); + stats.setRecentPauses(List.of(10L, 20L, 30L)); + stats.setPercentiles(new GCStats.PausePercentiles(15L, 25L, 40L, 60L, 100L)); + stats.setTotalMemory(1024L); + stats.setUsedMemory(512L); + stats.setFreeMemory(256L); + + // Verify all properties are still correct + assertEquals("Test GC", stats.getGcName()); + assertEquals(100L, stats.getCollectionCount()); + assertEquals(5000L, stats.getCollectionTime()); + assertEquals(50L, stats.getLastPauseDuration()); + assertEquals(List.of(10L, 20L, 30L), stats.getRecentPauses()); + assertEquals(15L, stats.getPercentiles().getP50()); + assertEquals(1024L, stats.getTotalMemory()); + assertEquals(512L, stats.getUsedMemory()); + assertEquals(256L, stats.getFreeMemory()); + } + + @Test + @DisplayName("Should handle independent property modifications") + void shouldHandleIndependentPropertyModifications() { + GCStats stats = new GCStats(); + + // Set initial values + stats.setCollectionCount(100L); + stats.setCollectionTime(5000L); + + // Modify one property + stats.setCollectionCount(200L); + + // Verify only the modified property changed + assertEquals(200L, stats.getCollectionCount(), "Modified property should change"); + assertEquals(5000L, stats.getCollectionTime(), "Unmodified property should remain unchanged"); + } + } + + @Nested + @DisplayName("Edge Cases Tests") + class EdgeCasesTests { + + @Test + @DisplayName("Should handle Long.MAX_VALUE for all numeric properties") + void shouldHandleLongMaxValueForAllNumericProperties() { + GCStats stats = new GCStats(); + long maxValue = Long.MAX_VALUE; + + stats.setCollectionCount(maxValue); + stats.setCollectionTime(maxValue); + stats.setLastPauseDuration(maxValue); + stats.setTotalMemory(maxValue); + stats.setUsedMemory(maxValue); + stats.setFreeMemory(maxValue); + + assertEquals(maxValue, stats.getCollectionCount()); + assertEquals(maxValue, stats.getCollectionTime()); + assertEquals(maxValue, stats.getLastPauseDuration()); + assertEquals(maxValue, stats.getTotalMemory()); + assertEquals(maxValue, stats.getUsedMemory()); + assertEquals(maxValue, stats.getFreeMemory()); + } + + @Test + @DisplayName("Should handle Long.MIN_VALUE for all numeric properties") + void shouldHandleLongMinValueForAllNumericProperties() { + GCStats stats = new GCStats(); + long minValue = Long.MIN_VALUE; + + stats.setCollectionCount(minValue); + stats.setCollectionTime(minValue); + stats.setLastPauseDuration(minValue); + stats.setTotalMemory(minValue); + stats.setUsedMemory(minValue); + stats.setFreeMemory(minValue); + + assertEquals(minValue, stats.getCollectionCount()); + assertEquals(minValue, stats.getCollectionTime()); + assertEquals(minValue, stats.getLastPauseDuration()); + assertEquals(minValue, stats.getTotalMemory()); + assertEquals(minValue, stats.getUsedMemory()); + assertEquals(minValue, stats.getFreeMemory()); + } } - } - - @Nested - @DisplayName("GC Name Tests") - class GCNameTests { - - @Test - @DisplayName("Should set and get GC name correctly") - void shouldSetAndGetGCNameCorrectly() { - GCStats stats = new GCStats(); - String gcName = "G1 Young Generation"; - - stats.setGcName(gcName); - assertEquals(gcName, stats.getGcName(), "GC name should be set and retrieved correctly"); - } - - @Test - @DisplayName("Should handle null GC name") - void shouldHandleNullGCName() { - GCStats stats = new GCStats(); - - assertDoesNotThrow(() -> stats.setGcName(null), "Setting null GC name should not throw"); - assertNull(stats.getGcName(), "GC name should be null"); - } - - @Test - @DisplayName("Should handle empty GC name") - void shouldHandleEmptyGCName() { - GCStats stats = new GCStats(); - String emptyName = ""; - - stats.setGcName(emptyName); - assertEquals(emptyName, stats.getGcName(), "Empty GC name should be handled"); - } - - @Test - @DisplayName("Should handle special characters in GC name") - void shouldHandleSpecialCharactersInGCName() { - GCStats stats = new GCStats(); - String specialName = "G1/Young (Test) @#$%^&*()"; - - stats.setGcName(specialName); - assertEquals(specialName, stats.getGcName(), "Special characters should be preserved"); - } - } - - @Nested - @DisplayName("Collection Statistics Tests") - class CollectionStatisticsTests { - - @Test - @DisplayName("Should set and get collection count correctly") - void shouldSetAndGetCollectionCountCorrectly() { - GCStats stats = new GCStats(); - long collectionCount = 12345L; - - stats.setCollectionCount(collectionCount); - assertEquals( - collectionCount, - stats.getCollectionCount(), - "Collection count should be set and retrieved correctly"); - } - - @Test - @DisplayName("Should handle zero collection count") - void shouldHandleZeroCollectionCount() { - GCStats stats = new GCStats(); - - stats.setCollectionCount(0L); - assertEquals(0L, stats.getCollectionCount(), "Zero collection count should be handled"); - } - - @Test - @DisplayName("Should handle negative collection count") - void shouldHandleNegativeCollectionCount() { - GCStats stats = new GCStats(); - long negativeCount = -100L; - - stats.setCollectionCount(negativeCount); - assertEquals( - negativeCount, stats.getCollectionCount(), "Negative collection count should be handled"); - } - - @Test - @DisplayName("Should handle very large collection count") - void shouldHandleVeryLargeCollectionCount() { - GCStats stats = new GCStats(); - long largeCount = Long.MAX_VALUE; - - stats.setCollectionCount(largeCount); - assertEquals( - largeCount, stats.getCollectionCount(), "Very large collection count should be handled"); - } - - @Test - @DisplayName("Should set and get collection time correctly") - void shouldSetAndGetCollectionTimeCorrectly() { - GCStats stats = new GCStats(); - long collectionTime = 98765L; - - stats.setCollectionTime(collectionTime); - assertEquals( - collectionTime, - stats.getCollectionTime(), - "Collection time should be set and retrieved correctly"); - } - - @Test - @DisplayName("Should handle zero collection time") - void shouldHandleZeroCollectionTime() { - GCStats stats = new GCStats(); - - stats.setCollectionTime(0L); - assertEquals(0L, stats.getCollectionTime(), "Zero collection time should be handled"); - } - - @Test - @DisplayName("Should handle negative collection time") - void shouldHandleNegativeCollectionTime() { - GCStats stats = new GCStats(); - long negativeTime = -500L; - - stats.setCollectionTime(negativeTime); - assertEquals( - negativeTime, stats.getCollectionTime(), "Negative collection time should be handled"); - } - } - - @Nested - @DisplayName("Pause Duration Tests") - class PauseDurationTests { - - @Test - @DisplayName("Should set and get last pause duration correctly") - void shouldSetAndGetLastPauseDurationCorrectly() { - GCStats stats = new GCStats(); - long pauseDuration = 150L; - - stats.setLastPauseDuration(pauseDuration); - assertEquals( - pauseDuration, - stats.getLastPauseDuration(), - "Last pause duration should be set and retrieved correctly"); - } - - @Test - @DisplayName("Should handle zero pause duration") - void shouldHandleZeroPauseDuration() { - GCStats stats = new GCStats(); - - stats.setLastPauseDuration(0L); - assertEquals(0L, stats.getLastPauseDuration(), "Zero pause duration should be handled"); - } - - @Test - @DisplayName("Should handle very long pause duration") - void shouldHandleVeryLongPauseDuration() { - GCStats stats = new GCStats(); - long longPause = 30000L; // 30 seconds - - stats.setLastPauseDuration(longPause); - assertEquals( - longPause, stats.getLastPauseDuration(), "Very long pause duration should be handled"); - } - - @Test - @DisplayName("Should handle microsecond-level pause duration") - void shouldHandleMicrosecondLevelPauseDuration() { - GCStats stats = new GCStats(); - long microPause = 1L; // 1 millisecond (micro in practice) - - stats.setLastPauseDuration(microPause); - assertEquals( - microPause, - stats.getLastPauseDuration(), - "Microsecond-level pause duration should be handled"); - } - } - - @Nested - @DisplayName("Recent Pauses Tests") - class RecentPausesTests { - - @Test - @DisplayName("Should set and get recent pauses list correctly") - void shouldSetAndGetRecentPausesListCorrectly() { - GCStats stats = new GCStats(); - List pauses = Arrays.asList(10L, 25L, 15L, 30L, 12L); - - stats.setRecentPauses(pauses); - assertEquals( - pauses, - stats.getRecentPauses(), - "Recent pauses list should be set and retrieved correctly"); - } - - @Test - @DisplayName("Should handle empty recent pauses list") - void shouldHandleEmptyRecentPausesList() { - GCStats stats = new GCStats(); - List emptyList = List.of(); - - stats.setRecentPauses(emptyList); - assertEquals( - emptyList, stats.getRecentPauses(), "Empty recent pauses list should be handled"); - } - - @Test - @DisplayName("Should handle null recent pauses list") - void shouldHandleNullRecentPausesList() { - GCStats stats = new GCStats(); - - assertDoesNotThrow( - () -> stats.setRecentPauses(null), "Setting null recent pauses should not throw"); - assertNull(stats.getRecentPauses(), "Recent pauses should be null"); - } - - @Test - @DisplayName("Should handle recent pauses with zero values") - void shouldHandleRecentPausesWithZeroValues() { - GCStats stats = new GCStats(); - List zeros = Arrays.asList(0L, 0L, 0L, 0L); - - stats.setRecentPauses(zeros); - assertEquals(zeros, stats.getRecentPauses(), "Zero pause values should be handled"); - } - - @Test - @DisplayName("Should handle recent pauses with mixed values") - void shouldHandleRecentPausesWithMixedValues() { - GCStats stats = new GCStats(); - List mixedValues = Arrays.asList(5L, 0L, 1000L, 1L, 500L); - - stats.setRecentPauses(mixedValues); - assertEquals(mixedValues, stats.getRecentPauses(), "Mixed pause values should be handled"); - } - } - - @Nested - @DisplayName("Percentiles Tests") - class PercentilesTests { - - @Test - @DisplayName("Should set and get percentiles correctly") - void shouldSetAndGetPercentilesCorrectly() { - GCStats stats = new GCStats(); - GCStats.PausePercentiles percentiles = - new GCStats.PausePercentiles(10L, 25L, 50L, 100L, 200L); - - stats.setPercentiles(percentiles); - assertEquals( - percentiles, stats.getPercentiles(), "Percentiles should be set and retrieved correctly"); - } - - @Test - @DisplayName("Should handle null percentiles") - void shouldHandleNullPercentiles() { - GCStats stats = new GCStats(); - - assertDoesNotThrow( - () -> stats.setPercentiles(null), "Setting null percentiles should not throw"); - assertNull(stats.getPercentiles(), "Percentiles should be null"); - } - - @Test - @DisplayName("Should handle all zero percentile values") - void shouldHandleAllZeroPercentileValues() { - GCStats stats = new GCStats(); - GCStats.PausePercentiles zeroPercentiles = new GCStats.PausePercentiles(0L, 0L, 0L, 0L, 0L); - - stats.setPercentiles(zeroPercentiles); - assertEquals(0L, stats.getPercentiles().getP50(), "P50 should be zero"); - assertEquals(0L, stats.getPercentiles().getP95(), "P95 should be zero"); - assertEquals(0L, stats.getPercentiles().getP99(), "P99 should be zero"); - assertEquals(0L, stats.getPercentiles().getP999(), "P999 should be zero"); - assertEquals(0L, stats.getPercentiles().getMax(), "Max should be zero"); - } - - @Test - @DisplayName("Should handle percentile with constructor") - void shouldHandlePercentileWithConstructor() { - long p50 = 15L, p95 = 50L, p99 = 120L, p999 = 300L, max = 500L; - GCStats.PausePercentiles percentiles = new GCStats.PausePercentiles(p50, p95, p99, p999, max); - - assertEquals(p50, percentiles.getP50(), "P50 should match constructor value"); - assertEquals(p95, percentiles.getP95(), "P95 should match constructor value"); - assertEquals(p99, percentiles.getP99(), "P99 should match constructor value"); - assertEquals(p999, percentiles.getP999(), "P999 should match constructor value"); - assertEquals(max, percentiles.getMax(), "Max should match constructor value"); - } - - @Test - @DisplayName("Should handle percentile setters and getters") - void shouldHandlePercentileSettersAndGetters() { - GCStats.PausePercentiles percentiles = new GCStats.PausePercentiles(); - - long p50 = 25L, p95 = 75L, p99 = 150L, p999 = 400L, max = 800L; - - percentiles.setP50(p50); - percentiles.setP95(p95); - percentiles.setP99(p99); - percentiles.setP999(p999); - percentiles.setMax(max); - - assertEquals(p50, percentiles.getP50(), "P50 should be set correctly"); - assertEquals(p95, percentiles.getP95(), "P95 should be set correctly"); - assertEquals(p99, percentiles.getP99(), "P99 should be set correctly"); - assertEquals(p999, percentiles.getP999(), "P999 should be set correctly"); - assertEquals(max, percentiles.getMax(), "Max should be set correctly"); - } - } - - @Nested - @DisplayName("Memory Statistics Tests") - class MemoryStatisticsTests { - - @Test - @DisplayName("Should set and get total memory correctly") - void shouldSetAndGetTotalMemoryCorrectly() { - GCStats stats = new GCStats(); - long totalMemory = 1024L * 1024L * 1024L; // 1GB - - stats.setTotalMemory(totalMemory); - assertEquals( - totalMemory, - stats.getTotalMemory(), - "Total memory should be set and retrieved correctly"); - } - - @Test - @DisplayName("Should set and get used memory correctly") - void shouldSetAndGetUsedMemoryCorrectly() { - GCStats stats = new GCStats(); - long usedMemory = 512L * 1024L * 1024L; // 512MB - - stats.setUsedMemory(usedMemory); - assertEquals( - usedMemory, stats.getUsedMemory(), "Used memory should be set and retrieved correctly"); - } - - @Test - @DisplayName("Should set and get free memory correctly") - void shouldSetAndGetFreeMemoryCorrectly() { - GCStats stats = new GCStats(); - long freeMemory = 256L * 1024L * 1024L; // 256MB - - stats.setFreeMemory(freeMemory); - assertEquals( - freeMemory, stats.getFreeMemory(), "Free memory should be set and retrieved correctly"); - } - - @Test - @DisplayName("Should maintain memory relationship: total >= used") - void shouldMaintainMemoryRelationshipTotalGTEUsed() { - GCStats stats = new GCStats(); - long total = 1000L; - long used = 750L; - - stats.setTotalMemory(total); - stats.setUsedMemory(used); - - assertTrue( - stats.getTotalMemory() >= stats.getUsedMemory(), - "Total memory should be greater than or equal to used memory"); - } - - @Test - @DisplayName("Should maintain memory relationship: total >= free") - void shouldMaintainMemoryRelationshipTotalGTFree() { - GCStats stats = new GCStats(); - long total = 1000L; - long free = 300L; - - stats.setTotalMemory(total); - stats.setFreeMemory(free); - - assertTrue( - stats.getTotalMemory() >= stats.getFreeMemory(), - "Total memory should be greater than or equal to free memory"); - } - - @Test - @DisplayName("Should maintain memory relationship: total >= used + free") - void shouldMaintainMemoryRelationshipTotalGEUsedPlusFree() { - GCStats stats = new GCStats(); - long total = 1000L; - long used = 600L; - long free = 400L; - - stats.setTotalMemory(total); - stats.setUsedMemory(used); - stats.setFreeMemory(free); - - assertTrue( - stats.getTotalMemory() >= stats.getUsedMemory() + stats.getFreeMemory(), - "Total memory should be greater than or equal to used + free memory"); - } - - @Test - @DisplayName("Should handle zero memory values") - void shouldHandleZeroMemoryValues() { - GCStats stats = new GCStats(); - - stats.setTotalMemory(0L); - stats.setUsedMemory(0L); - stats.setFreeMemory(0L); - - assertEquals(0L, stats.getTotalMemory(), "Total memory should handle zero"); - assertEquals(0L, stats.getUsedMemory(), "Used memory should handle zero"); - assertEquals(0L, stats.getFreeMemory(), "Free memory should handle zero"); - } - - @Test - @DisplayName("Should handle large memory values") - void shouldHandleLargeMemoryValues() { - GCStats stats = new GCStats(); - long largeValue = Long.MAX_VALUE / 2; // Half of max long value - - stats.setTotalMemory(largeValue); - stats.setUsedMemory(largeValue / 2); - stats.setFreeMemory(largeValue / 4); - - assertEquals(largeValue, stats.getTotalMemory(), "Large total memory should be handled"); - assertEquals(largeValue / 2, stats.getUsedMemory(), "Large used memory should be handled"); - assertEquals(largeValue / 4, stats.getFreeMemory(), "Large free memory should be handled"); - } - } - - @Nested - @DisplayName("Consistency Tests") - class ConsistencyTests { - - @Test - @DisplayName("Should maintain consistent state across multiple property changes") - void shouldMaintainConsistentStateAcrossMultiplePropertyChanges() { - GCStats stats = new GCStats(); - - // Set all properties - stats.setGcName("Test GC"); - stats.setCollectionCount(100L); - stats.setCollectionTime(5000L); - stats.setLastPauseDuration(50L); - stats.setRecentPauses(List.of(10L, 20L, 30L)); - stats.setPercentiles(new GCStats.PausePercentiles(15L, 25L, 40L, 60L, 100L)); - stats.setTotalMemory(1024L); - stats.setUsedMemory(512L); - stats.setFreeMemory(256L); - - // Verify all properties are still correct - assertEquals("Test GC", stats.getGcName()); - assertEquals(100L, stats.getCollectionCount()); - assertEquals(5000L, stats.getCollectionTime()); - assertEquals(50L, stats.getLastPauseDuration()); - assertEquals(List.of(10L, 20L, 30L), stats.getRecentPauses()); - assertEquals(15L, stats.getPercentiles().getP50()); - assertEquals(1024L, stats.getTotalMemory()); - assertEquals(512L, stats.getUsedMemory()); - assertEquals(256L, stats.getFreeMemory()); - } - - @Test - @DisplayName("Should handle independent property modifications") - void shouldHandleIndependentPropertyModifications() { - GCStats stats = new GCStats(); - - // Set initial values - stats.setCollectionCount(100L); - stats.setCollectionTime(5000L); - - // Modify one property - stats.setCollectionCount(200L); - - // Verify only the modified property changed - assertEquals(200L, stats.getCollectionCount(), "Modified property should change"); - assertEquals(5000L, stats.getCollectionTime(), "Unmodified property should remain unchanged"); - } - } - - @Nested - @DisplayName("Edge Cases Tests") - class EdgeCasesTests { - - @Test - @DisplayName("Should handle Long.MAX_VALUE for all numeric properties") - void shouldHandleLongMaxValueForAllNumericProperties() { - GCStats stats = new GCStats(); - long maxValue = Long.MAX_VALUE; - - stats.setCollectionCount(maxValue); - stats.setCollectionTime(maxValue); - stats.setLastPauseDuration(maxValue); - stats.setTotalMemory(maxValue); - stats.setUsedMemory(maxValue); - stats.setFreeMemory(maxValue); - - assertEquals(maxValue, stats.getCollectionCount()); - assertEquals(maxValue, stats.getCollectionTime()); - assertEquals(maxValue, stats.getLastPauseDuration()); - assertEquals(maxValue, stats.getTotalMemory()); - assertEquals(maxValue, stats.getUsedMemory()); - assertEquals(maxValue, stats.getFreeMemory()); - } - - @Test - @DisplayName("Should handle Long.MIN_VALUE for all numeric properties") - void shouldHandleLongMinValueForAllNumericProperties() { - GCStats stats = new GCStats(); - long minValue = Long.MIN_VALUE; - - stats.setCollectionCount(minValue); - stats.setCollectionTime(minValue); - stats.setLastPauseDuration(minValue); - stats.setTotalMemory(minValue); - stats.setUsedMemory(minValue); - stats.setFreeMemory(minValue); - - assertEquals(minValue, stats.getCollectionCount()); - assertEquals(minValue, stats.getCollectionTime()); - assertEquals(minValue, stats.getLastPauseDuration()); - assertEquals(minValue, stats.getTotalMemory()); - assertEquals(minValue, stats.getUsedMemory()); - assertEquals(minValue, stats.getFreeMemory()); - } - } } diff --git a/src/test/java/fish/payara/trader/monitoring/SLAMonitorServiceTest.java b/src/test/java/fish/payara/trader/monitoring/SLAMonitorServiceTest.java index afbda1f..cda483d 100644 --- a/src/test/java/fish/payara/trader/monitoring/SLAMonitorServiceTest.java +++ b/src/test/java/fish/payara/trader/monitoring/SLAMonitorServiceTest.java @@ -9,111 +9,111 @@ @DisplayName("SLAMonitorService Tests") class SLAMonitorServiceTest { - private SLAMonitorService slaMonitorService; - - @BeforeEach - void setUp() { - slaMonitorService = new SLAMonitorService(); - } - - @Test - @DisplayName("Should initialize with zeroed stats") - void shouldInitializeWithZeroedStats() { - SLAMonitorService.SLAStats stats = slaMonitorService.getStats(); - - assertEquals(0, stats.totalOperations); - assertEquals(0, stats.violationsOver10ms); - assertEquals(0, stats.violationsOver50ms); - assertEquals(0, stats.violationsOver100ms); - assertEquals(0.0, stats.violationRate); - assertEquals(0, stats.recentViolations); - } - - @Test - @DisplayName("Should record normal operation below thresholds") - void shouldRecordNormalOperationBelowThresholds() { - slaMonitorService.recordOperation(5); - - SLAMonitorService.SLAStats stats = slaMonitorService.getStats(); - assertEquals(1, stats.totalOperations); - assertEquals(0, stats.violationsOver10ms); - assertEquals(0.0, stats.violationRate); - } - - @Test - @DisplayName("Should record violation over 10ms") - void shouldRecordViolationOver10ms() { - slaMonitorService.recordOperation(15); - - SLAMonitorService.SLAStats stats = slaMonitorService.getStats(); - assertEquals(1, stats.totalOperations); - assertEquals(1, stats.violationsOver10ms); - assertEquals(0, stats.violationsOver50ms); - assertEquals(0, stats.violationsOver100ms); - assertEquals(100.0, stats.violationRate); - assertEquals(1, stats.recentViolations); - } - - @Test - @DisplayName("Should record violation over 50ms") - void shouldRecordViolationOver50ms() { - slaMonitorService.recordOperation(55); - - SLAMonitorService.SLAStats stats = slaMonitorService.getStats(); - assertEquals(1, stats.totalOperations); - assertEquals(1, stats.violationsOver10ms); - assertEquals(1, stats.violationsOver50ms); - assertEquals(0, stats.violationsOver100ms); - assertEquals(1, stats.recentViolations); - } - - @Test - @DisplayName("Should record violation over 100ms") - void shouldRecordViolationOver100ms() { - slaMonitorService.recordOperation(105); - - SLAMonitorService.SLAStats stats = slaMonitorService.getStats(); - assertEquals(1, stats.totalOperations); - assertEquals(1, stats.violationsOver10ms); - assertEquals(1, stats.violationsOver50ms); - assertEquals(1, stats.violationsOver100ms); - assertEquals(1, stats.recentViolations); - } - - @Test - @DisplayName("Should calculate violation rate correctly") - void shouldCalculateViolationRateCorrectly() { - slaMonitorService.recordOperation(5); // No violation - slaMonitorService.recordOperation(15); // Violation > 10ms - slaMonitorService.recordOperation(5); // No violation - slaMonitorService.recordOperation(20); // Violation > 10ms - - SLAMonitorService.SLAStats stats = slaMonitorService.getStats(); - assertEquals(4, stats.totalOperations); - assertEquals(2, stats.violationsOver10ms); - assertEquals(50.0, stats.violationRate); - } - - @Test - @DisplayName("Should reset statistics") - void shouldResetStatistics() { - slaMonitorService.recordOperation(105); - slaMonitorService.reset(); - - SLAMonitorService.SLAStats stats = slaMonitorService.getStats(); - assertEquals(0, stats.totalOperations); - assertEquals(0, stats.violationsOver10ms); - assertEquals(0, stats.recentViolations); - } - - @Test - @DisplayName("Should handle multiple violations in a window") - void shouldHandleMultipleViolationsInWindow() { - for (int i = 0; i < 10; i++) { - slaMonitorService.recordOperation(15); + private SLAMonitorService slaMonitorService; + + @BeforeEach + void setUp() { + slaMonitorService = new SLAMonitorService(); + } + + @Test + @DisplayName("Should initialize with zeroed stats") + void shouldInitializeWithZeroedStats() { + SLAMonitorService.SLAStats stats = slaMonitorService.getStats(); + + assertEquals(0, stats.totalOperations); + assertEquals(0, stats.violationsOver10ms); + assertEquals(0, stats.violationsOver50ms); + assertEquals(0, stats.violationsOver100ms); + assertEquals(0.0, stats.violationRate); + assertEquals(0, stats.recentViolations); + } + + @Test + @DisplayName("Should record normal operation below thresholds") + void shouldRecordNormalOperationBelowThresholds() { + slaMonitorService.recordOperation(5); + + SLAMonitorService.SLAStats stats = slaMonitorService.getStats(); + assertEquals(1, stats.totalOperations); + assertEquals(0, stats.violationsOver10ms); + assertEquals(0.0, stats.violationRate); + } + + @Test + @DisplayName("Should record violation over 10ms") + void shouldRecordViolationOver10ms() { + slaMonitorService.recordOperation(15); + + SLAMonitorService.SLAStats stats = slaMonitorService.getStats(); + assertEquals(1, stats.totalOperations); + assertEquals(1, stats.violationsOver10ms); + assertEquals(0, stats.violationsOver50ms); + assertEquals(0, stats.violationsOver100ms); + assertEquals(100.0, stats.violationRate); + assertEquals(1, stats.recentViolations); + } + + @Test + @DisplayName("Should record violation over 50ms") + void shouldRecordViolationOver50ms() { + slaMonitorService.recordOperation(55); + + SLAMonitorService.SLAStats stats = slaMonitorService.getStats(); + assertEquals(1, stats.totalOperations); + assertEquals(1, stats.violationsOver10ms); + assertEquals(1, stats.violationsOver50ms); + assertEquals(0, stats.violationsOver100ms); + assertEquals(1, stats.recentViolations); } - SLAMonitorService.SLAStats stats = slaMonitorService.getStats(); - assertEquals(10, stats.recentViolations); - } + @Test + @DisplayName("Should record violation over 100ms") + void shouldRecordViolationOver100ms() { + slaMonitorService.recordOperation(105); + + SLAMonitorService.SLAStats stats = slaMonitorService.getStats(); + assertEquals(1, stats.totalOperations); + assertEquals(1, stats.violationsOver10ms); + assertEquals(1, stats.violationsOver50ms); + assertEquals(1, stats.violationsOver100ms); + assertEquals(1, stats.recentViolations); + } + + @Test + @DisplayName("Should calculate violation rate correctly") + void shouldCalculateViolationRateCorrectly() { + slaMonitorService.recordOperation(5); // No violation + slaMonitorService.recordOperation(15); // Violation > 10ms + slaMonitorService.recordOperation(5); // No violation + slaMonitorService.recordOperation(20); // Violation > 10ms + + SLAMonitorService.SLAStats stats = slaMonitorService.getStats(); + assertEquals(4, stats.totalOperations); + assertEquals(2, stats.violationsOver10ms); + assertEquals(50.0, stats.violationRate); + } + + @Test + @DisplayName("Should reset statistics") + void shouldResetStatistics() { + slaMonitorService.recordOperation(105); + slaMonitorService.reset(); + + SLAMonitorService.SLAStats stats = slaMonitorService.getStats(); + assertEquals(0, stats.totalOperations); + assertEquals(0, stats.violationsOver10ms); + assertEquals(0, stats.recentViolations); + } + + @Test + @DisplayName("Should handle multiple violations in a window") + void shouldHandleMultipleViolationsInWindow() { + for (int i = 0; i < 10; i++) { + slaMonitorService.recordOperation(15); + } + + SLAMonitorService.SLAStats stats = slaMonitorService.getStats(); + assertEquals(10, stats.recentViolations); + } } diff --git a/src/test/java/fish/payara/trader/pressure/AllocationModeTest.java b/src/test/java/fish/payara/trader/pressure/AllocationModeTest.java index 221dceb..6dbf0db 100644 --- a/src/test/java/fish/payara/trader/pressure/AllocationModeTest.java +++ b/src/test/java/fish/payara/trader/pressure/AllocationModeTest.java @@ -13,121 +13,110 @@ @DisplayName("AllocationMode Tests") class AllocationModeTest { - @Nested - @DisplayName("Enum Values Tests") - class EnumValuesTests { - - @Test - @DisplayName("Should have all required allocation modes") - void shouldHaveAllRequiredAllocationModes() { - AllocationMode[] modes = AllocationMode.values(); - assertEquals(6, modes.length, "Should have exactly 6 allocation modes"); - - assertTrue(containsMode(modes, AllocationMode.OFF), "Should contain OFF mode"); - assertTrue( - containsMode(modes, AllocationMode.STEADY_LOAD), "Should contain STEADY_LOAD mode"); - assertTrue( - containsMode(modes, AllocationMode.GROWING_HEAP), "Should contain GROWING_HEAP mode"); - assertTrue( - containsMode(modes, AllocationMode.PROMOTION_STORM), - "Should contain PROMOTION_STORM mode"); - assertTrue( - containsMode(modes, AllocationMode.FRAGMENTATION), "Should contain FRAGMENTATION mode"); - assertTrue( - containsMode(modes, AllocationMode.CROSS_GEN_REFS), "Should contain CROSS_GEN_REFS mode"); + @Nested + @DisplayName("Enum Values Tests") + class EnumValuesTests { + + @Test + @DisplayName("Should have all required allocation modes") + void shouldHaveAllRequiredAllocationModes() { + AllocationMode[] modes = AllocationMode.values(); + assertEquals(6, modes.length, "Should have exactly 6 allocation modes"); + + assertTrue(containsMode(modes, AllocationMode.OFF), "Should contain OFF mode"); + assertTrue(containsMode(modes, AllocationMode.STEADY_LOAD), "Should contain STEADY_LOAD mode"); + assertTrue(containsMode(modes, AllocationMode.GROWING_HEAP), "Should contain GROWING_HEAP mode"); + assertTrue(containsMode(modes, AllocationMode.PROMOTION_STORM), "Should contain PROMOTION_STORM mode"); + assertTrue(containsMode(modes, AllocationMode.FRAGMENTATION), "Should contain FRAGMENTATION mode"); + assertTrue(containsMode(modes, AllocationMode.CROSS_GEN_REFS), "Should contain CROSS_GEN_REFS mode"); + } } - } - @Nested - @DisplayName("Scenario Properties Tests") - class ScenarioPropertiesTests { + @Nested + @DisplayName("Scenario Properties Tests") + class ScenarioPropertiesTests { - @ParameterizedTest - @EnumSource(AllocationMode.class) - @DisplayName("Should have non-null scenario type") - void shouldHaveNonNullScenarioType(AllocationMode mode) { - assertNotNull(mode.getScenarioType(), "ScenarioType should not be null"); - } + @ParameterizedTest + @EnumSource(AllocationMode.class) + @DisplayName("Should have non-null scenario type") + void shouldHaveNonNullScenarioType(AllocationMode mode) { + assertNotNull(mode.getScenarioType(), "ScenarioType should not be null"); + } - @Test - @DisplayName("Should have correct scenario types") - void shouldHaveCorrectScenarioTypes() { - assertEquals(ScenarioType.NONE, AllocationMode.OFF.getScenarioType()); - assertEquals(ScenarioType.STEADY, AllocationMode.STEADY_LOAD.getScenarioType()); - assertEquals(ScenarioType.GROWING, AllocationMode.GROWING_HEAP.getScenarioType()); - assertEquals(ScenarioType.PROMOTION, AllocationMode.PROMOTION_STORM.getScenarioType()); - assertEquals(ScenarioType.FRAGMENTATION, AllocationMode.FRAGMENTATION.getScenarioType()); - assertEquals(ScenarioType.CROSS_REF, AllocationMode.CROSS_GEN_REFS.getScenarioType()); - } + @Test + @DisplayName("Should have correct scenario types") + void shouldHaveCorrectScenarioTypes() { + assertEquals(ScenarioType.NONE, AllocationMode.OFF.getScenarioType()); + assertEquals(ScenarioType.STEADY, AllocationMode.STEADY_LOAD.getScenarioType()); + assertEquals(ScenarioType.GROWING, AllocationMode.GROWING_HEAP.getScenarioType()); + assertEquals(ScenarioType.PROMOTION, AllocationMode.PROMOTION_STORM.getScenarioType()); + assertEquals(ScenarioType.FRAGMENTATION, AllocationMode.FRAGMENTATION.getScenarioType()); + assertEquals(ScenarioType.CROSS_REF, AllocationMode.CROSS_GEN_REFS.getScenarioType()); + } - @ParameterizedTest - @EnumSource(AllocationMode.class) - @DisplayName("Should have valid allocation rates") - void shouldHaveValidAllocationRates(AllocationMode mode) { - assertTrue(mode.getAllocationRateMBPerSec() >= 0, "Allocation rate should be non-negative"); - } + @ParameterizedTest + @EnumSource(AllocationMode.class) + @DisplayName("Should have valid allocation rates") + void shouldHaveValidAllocationRates(AllocationMode mode) { + assertTrue(mode.getAllocationRateMBPerSec() >= 0, "Allocation rate should be non-negative"); + } - @ParameterizedTest - @EnumSource(AllocationMode.class) - @DisplayName("Should have meaningful descriptions") - void shouldHaveMeaningfulDescriptions(AllocationMode mode) { - String description = mode.getDescription(); + @ParameterizedTest + @EnumSource(AllocationMode.class) + @DisplayName("Should have meaningful descriptions") + void shouldHaveMeaningfulDescriptions(AllocationMode mode) { + String description = mode.getDescription(); - assertNotNull(description, "Description should not be null"); - assertFalse(description.trim().isEmpty(), "Description should not be empty"); - } - } - - @Nested - @DisplayName("Edge Cases Tests") - class EdgeCasesTests { - - @Test - @DisplayName("Should handle OFF mode correctly") - void shouldHandleOffModeCorrectly() { - assertEquals(0, AllocationMode.OFF.getAllocationRateMBPerSec()); - assertEquals(0, AllocationMode.OFF.getLiveSetSizeMB()); - assertEquals(ScenarioType.NONE, AllocationMode.OFF.getScenarioType()); + assertNotNull(description, "Description should not be null"); + assertFalse(description.trim().isEmpty(), "Description should not be empty"); + } } - @ParameterizedTest - @ValueSource(strings = {"OFF", "STEADY_LOAD", "GROWING_HEAP", "PROMOTION_STORM"}) - @DisplayName("Should handle enum valueOf correctly") - void shouldHandleEnumValueOfCorrectly(String modeName) { - assertDoesNotThrow( - () -> { - AllocationMode mode = AllocationMode.valueOf(modeName); - assertNotNull(mode, "Mode should not be null"); - assertEquals(modeName, mode.name(), "Mode name should match"); - }, - "valueOf should work for all valid mode names"); - } + @Nested + @DisplayName("Edge Cases Tests") + class EdgeCasesTests { + + @Test + @DisplayName("Should handle OFF mode correctly") + void shouldHandleOffModeCorrectly() { + assertEquals(0, AllocationMode.OFF.getAllocationRateMBPerSec()); + assertEquals(0, AllocationMode.OFF.getLiveSetSizeMB()); + assertEquals(ScenarioType.NONE, AllocationMode.OFF.getScenarioType()); + } - @Test - @DisplayName("Should throw IllegalArgumentException for invalid mode names") - void shouldThrowIllegalArgumentExceptionForInvalidModeNames() { - String[] invalidNames = {"INVALID", "low", "high", "", " ", null}; - - for (String invalidName : invalidNames) { - if (invalidName != null) { - assertThrows( - IllegalArgumentException.class, - () -> { - AllocationMode.valueOf(invalidName); - }, - () -> String.format("Should throw for invalid mode name: '%s'", invalidName)); + @ParameterizedTest + @ValueSource(strings = {"OFF", "STEADY_LOAD", "GROWING_HEAP", "PROMOTION_STORM"}) + @DisplayName("Should handle enum valueOf correctly") + void shouldHandleEnumValueOfCorrectly(String modeName) { + assertDoesNotThrow(() -> { + AllocationMode mode = AllocationMode.valueOf(modeName); + assertNotNull(mode, "Mode should not be null"); + assertEquals(modeName, mode.name(), "Mode name should match"); + }, "valueOf should work for all valid mode names"); + } + + @Test + @DisplayName("Should throw IllegalArgumentException for invalid mode names") + void shouldThrowIllegalArgumentExceptionForInvalidModeNames() { + String[] invalidNames = {"INVALID", "low", "high", "", " ", null}; + + for (String invalidName : invalidNames) { + if (invalidName != null) { + assertThrows(IllegalArgumentException.class, () -> { + AllocationMode.valueOf(invalidName); + }, () -> String.format("Should throw for invalid mode name: '%s'", invalidName)); + } + } } - } } - } - - /** Helper method to check if array contains specific mode */ - private boolean containsMode(AllocationMode[] modes, AllocationMode targetMode) { - for (AllocationMode mode : modes) { - if (mode == targetMode) { - return true; - } + + /** Helper method to check if array contains specific mode */ + private boolean containsMode(AllocationMode[] modes, AllocationMode targetMode) { + for (AllocationMode mode : modes) { + if (mode == targetMode) { + return true; + } + } + return false; } - return false; - } } diff --git a/src/test/java/fish/payara/trader/rest/GCStatsResourceTest.java b/src/test/java/fish/payara/trader/rest/GCStatsResourceTest.java index 863b737..23f23bc 100644 --- a/src/test/java/fish/payara/trader/rest/GCStatsResourceTest.java +++ b/src/test/java/fish/payara/trader/rest/GCStatsResourceTest.java @@ -23,78 +23,82 @@ @DisplayName("GCStatsResource Tests") class GCStatsResourceTest { - @Mock private GCStatsService gcStatsService; - @Mock private MemoryPressureService memoryPressureService; - @Mock private MarketDataPublisher publisher; - @Mock private SLAMonitorService slaMonitor; - @Mock private GCPauseMonitor gcPauseMonitor; - - @InjectMocks private GCStatsResource resource; - - @Test - @DisplayName("Should return SLA stats") - void shouldReturnSLAStats() { - SLAMonitorService.SLAStats mockStats = new SLAMonitorService.SLAStats(100, 5, 2, 1, 5.0, 3); - when(slaMonitor.getStats()).thenReturn(mockStats); - - Response response = resource.getSLAStats(); - - assertEquals(Response.Status.OK.getStatusCode(), response.getStatus()); - assertEquals(mockStats, response.getEntity()); - } - - @Test - @DisplayName("Should reset SLA stats") - void shouldResetSLAStats() { - Response response = resource.resetSLAStats(); - - assertEquals(Response.Status.OK.getStatusCode(), response.getStatus()); - verify(slaMonitor).reset(); - } - - @Test - @DisplayName("Should return GC pause stats") - void shouldReturnGCPauseStats() { - GCPauseMonitor.GCPauseStats mockStats = - new GCPauseMonitor.GCPauseStats(10, 100, 10.0, 10, 20, 30, 40, 50, 5, 2, 1, 10); - when(gcPauseMonitor.getStats()).thenReturn(mockStats); - - Response response = resource.getGCPauseStats(); - - assertEquals(Response.Status.OK.getStatusCode(), response.getStatus()); - assertEquals(mockStats, response.getEntity()); - } - - @Test - @DisplayName("Should return accurate comparison data") - void shouldReturnAccurateComparisonData() { - when(memoryPressureService.getCurrentMode()).thenReturn(AllocationMode.STEADY_LOAD); - when(publisher.getMessagesPublished()).thenReturn(5000L); - when(gcStatsService.collectGCStats()).thenReturn(Collections.emptyList()); - - GCPauseMonitor.GCPauseStats mockPauseStats = - new GCPauseMonitor.GCPauseStats(10, 100, 10.0, 10, 20, 30, 40, 50, 5, 2, 1, 10); - when(gcPauseMonitor.getStats()).thenReturn(mockPauseStats); - - Response response = resource.getComparison(); - - assertEquals(Response.Status.OK.getStatusCode(), response.getStatus()); - @SuppressWarnings("unchecked") - Map entity = (Map) response.getEntity(); - - assertEquals(AllocationMode.STEADY_LOAD, entity.get("allocationMode")); - assertEquals(5000L, entity.get("messageRate")); - assertEquals(10.0, ((Number) entity.get("pauseP50Ms")).doubleValue()); - assertEquals(50L, entity.get("pauseMaxMs")); - assertEquals(5L, entity.get("slaViolations10ms")); - } - - @Test - @DisplayName("Should reset GC stats") - void shouldResetGCStats() { - Response response = resource.resetStats(); - - assertEquals(Response.Status.OK.getStatusCode(), response.getStatus()); - verify(gcStatsService).resetStats(); - } + @Mock + private GCStatsService gcStatsService; + @Mock + private MemoryPressureService memoryPressureService; + @Mock + private MarketDataPublisher publisher; + @Mock + private SLAMonitorService slaMonitor; + @Mock + private GCPauseMonitor gcPauseMonitor; + + @InjectMocks + private GCStatsResource resource; + + @Test + @DisplayName("Should return SLA stats") + void shouldReturnSLAStats() { + SLAMonitorService.SLAStats mockStats = new SLAMonitorService.SLAStats(100, 5, 2, 1, 5.0, 3); + when(slaMonitor.getStats()).thenReturn(mockStats); + + Response response = resource.getSLAStats(); + + assertEquals(Response.Status.OK.getStatusCode(), response.getStatus()); + assertEquals(mockStats, response.getEntity()); + } + + @Test + @DisplayName("Should reset SLA stats") + void shouldResetSLAStats() { + Response response = resource.resetSLAStats(); + + assertEquals(Response.Status.OK.getStatusCode(), response.getStatus()); + verify(slaMonitor).reset(); + } + + @Test + @DisplayName("Should return GC pause stats") + void shouldReturnGCPauseStats() { + GCPauseMonitor.GCPauseStats mockStats = new GCPauseMonitor.GCPauseStats(10, 100, 10.0, 10, 20, 30, 40, 50, 5, 2, 1, 10); + when(gcPauseMonitor.getStats()).thenReturn(mockStats); + + Response response = resource.getGCPauseStats(); + + assertEquals(Response.Status.OK.getStatusCode(), response.getStatus()); + assertEquals(mockStats, response.getEntity()); + } + + @Test + @DisplayName("Should return accurate comparison data") + void shouldReturnAccurateComparisonData() { + when(memoryPressureService.getCurrentMode()).thenReturn(AllocationMode.STEADY_LOAD); + when(publisher.getMessagesPublished()).thenReturn(5000L); + when(gcStatsService.collectGCStats()).thenReturn(Collections.emptyList()); + + GCPauseMonitor.GCPauseStats mockPauseStats = new GCPauseMonitor.GCPauseStats(10, 100, 10.0, 10, 20, 30, 40, 50, 5, 2, 1, 10); + when(gcPauseMonitor.getStats()).thenReturn(mockPauseStats); + + Response response = resource.getComparison(); + + assertEquals(Response.Status.OK.getStatusCode(), response.getStatus()); + @SuppressWarnings("unchecked") + Map entity = (Map) response.getEntity(); + + assertEquals(AllocationMode.STEADY_LOAD, entity.get("allocationMode")); + assertEquals(5000L, entity.get("messageRate")); + assertEquals(10.0, ((Number) entity.get("pauseP50Ms")).doubleValue()); + assertEquals(50L, entity.get("pauseMaxMs")); + assertEquals(5L, entity.get("slaViolations10ms")); + } + + @Test + @DisplayName("Should reset GC stats") + void shouldResetGCStats() { + Response response = resource.resetStats(); + + assertEquals(Response.Status.OK.getStatusCode(), response.getStatus()); + verify(gcStatsService).resetStats(); + } } diff --git a/src/test/java/fish/payara/trader/rest/MemoryPressureResourceTest.java b/src/test/java/fish/payara/trader/rest/MemoryPressureResourceTest.java index af99471..7dfd5c0 100644 --- a/src/test/java/fish/payara/trader/rest/MemoryPressureResourceTest.java +++ b/src/test/java/fish/payara/trader/rest/MemoryPressureResourceTest.java @@ -18,61 +18,62 @@ @DisplayName("MemoryPressureResource Tests") class MemoryPressureResourceTest { - @Mock private MemoryPressureService pressureService; + @Mock + private MemoryPressureService pressureService; - @InjectMocks private MemoryPressureResource resource; + @InjectMocks + private MemoryPressureResource resource; - @Test - @DisplayName("Should return current status") - void shouldReturnCurrentStatus() { - when(pressureService.getCurrentMode()).thenReturn(AllocationMode.STEADY_LOAD); - when(pressureService.isRunning()).thenReturn(true); + @Test + @DisplayName("Should return current status") + void shouldReturnCurrentStatus() { + when(pressureService.getCurrentMode()).thenReturn(AllocationMode.STEADY_LOAD); + when(pressureService.isRunning()).thenReturn(true); - Response response = resource.getStatus(); + Response response = resource.getStatus(); - assertEquals(Response.Status.OK.getStatusCode(), response.getStatus()); - @SuppressWarnings("unchecked") - Map entity = (Map) response.getEntity(); - assertEquals("STEADY_LOAD", entity.get("currentMode")); - assertEquals(true, entity.get("running")); - } + assertEquals(Response.Status.OK.getStatusCode(), response.getStatus()); + @SuppressWarnings("unchecked") + Map entity = (Map) response.getEntity(); + assertEquals("STEADY_LOAD", entity.get("currentMode")); + assertEquals(true, entity.get("running")); + } - @Test - @DisplayName("Should set allocation mode successfully") - void shouldSetAllocationModeSuccessfully() { - Response response = resource.setMode("steady_load"); + @Test + @DisplayName("Should set allocation mode successfully") + void shouldSetAllocationModeSuccessfully() { + Response response = resource.setMode("steady_load"); - assertEquals(Response.Status.OK.getStatusCode(), response.getStatus()); - verify(pressureService).setAllocationMode(AllocationMode.STEADY_LOAD); + assertEquals(Response.Status.OK.getStatusCode(), response.getStatus()); + verify(pressureService).setAllocationMode(AllocationMode.STEADY_LOAD); - @SuppressWarnings("unchecked") - Map entity = (Map) response.getEntity(); - assertTrue((Boolean) entity.get("success")); - assertEquals("STEADY_LOAD", entity.get("mode")); - } + @SuppressWarnings("unchecked") + Map entity = (Map) response.getEntity(); + assertTrue((Boolean) entity.get("success")); + assertEquals("STEADY_LOAD", entity.get("mode")); + } - @Test - @DisplayName("Should return bad request for invalid mode") - void shouldReturnBadRequestForInvalidMode() { - Response response = resource.setMode("invalid_mode"); + @Test + @DisplayName("Should return bad request for invalid mode") + void shouldReturnBadRequestForInvalidMode() { + Response response = resource.setMode("invalid_mode"); - assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), response.getStatus()); - @SuppressWarnings("unchecked") - Map entity = (Map) response.getEntity(); - assertFalse((Boolean) entity.get("success")); - assertTrue(((String) entity.get("error")).contains("Invalid mode")); - } + assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), response.getStatus()); + @SuppressWarnings("unchecked") + Map entity = (Map) response.getEntity(); + assertFalse((Boolean) entity.get("success")); + assertTrue(((String) entity.get("error")).contains("Invalid mode")); + } - @Test - @DisplayName("Should return all modes") - void shouldReturnAllModes() { - Response response = resource.getModes(); + @Test + @DisplayName("Should return all modes") + void shouldReturnAllModes() { + Response response = resource.getModes(); - assertEquals(Response.Status.OK.getStatusCode(), response.getStatus()); - @SuppressWarnings("unchecked") - Map> entity = - (Map>) response.getEntity(); - assertTrue(entity.containsKey("STEADY_LOAD")); - assertTrue(entity.containsKey("PROMOTION_STORM")); - } + assertEquals(Response.Status.OK.getStatusCode(), response.getStatus()); + @SuppressWarnings("unchecked") + Map> entity = (Map>) response.getEntity(); + assertTrue(entity.containsKey("STEADY_LOAD")); + assertTrue(entity.containsKey("PROMOTION_STORM")); + } } diff --git a/src/test/java/fish/payara/trader/rest/StatusResourceTest.java b/src/test/java/fish/payara/trader/rest/StatusResourceTest.java index 827c0a0..776ccac 100644 --- a/src/test/java/fish/payara/trader/rest/StatusResourceTest.java +++ b/src/test/java/fish/payara/trader/rest/StatusResourceTest.java @@ -21,359 +21,354 @@ @DisplayName("StatusResource Tests") class StatusResourceTest { - @Mock private AeronSubscriberBean subscriber; - - @Mock private MarketDataPublisher publisher; - - @Mock private MarketDataBroadcaster broadcaster; - - @InjectMocks private StatusResource statusResource; - - @Nested - @DisplayName("getStatus Method Tests") - class GetStatusMethodTests { - - @Test - @DisplayName("Should return complete status information") - void shouldReturnCompleteStatusInformation() { - // Arrange - String subscriberStatus = "Channel: aeron:ipc, Stream: 1001, Running: true"; - long localMessagesPublished = 5000L; - long clusterMessagesPublished = 25000L; - int activeSessions = 42; - String expectedInstanceName = "test-instance-1"; - - when(subscriber.getStatus()).thenReturn(subscriberStatus); - when(publisher.getMessagesPublished()).thenReturn(localMessagesPublished); - when(publisher.getClusterMessagesPublished()).thenReturn(clusterMessagesPublished); - when(broadcaster.getSessionCount()).thenReturn(activeSessions); - - // Set environment variable - note: tests can't modify real env vars, so we expect default - // behavior - // In this test, we expect "standalone" since we can't set actual environment variables - - try { - // Act - Response response = statusResource.getStatus(); - - // Assert - assertEquals(Response.Status.OK.getStatusCode(), response.getStatus()); - // Skip media type check as Response.getMediaType() may return null in tests - - @SuppressWarnings("unchecked") - Map status = (Map) response.getEntity(); - - // Verify required fields - assertEquals("TradeStreamEE", status.get("application")); - assertEquals( - "High-frequency trading dashboard with Aeron and SBE", status.get("description")); - assertEquals("standalone", status.get("instance")); // Default value - assertEquals(subscriberStatus, status.get("subscriber")); - assertEquals("UP", status.get("status")); - - // Verify publisher stats - @SuppressWarnings("unchecked") - Map publisherStats = (Map) status.get("publisher"); - assertEquals(localMessagesPublished, publisherStats.get("localMessagesPublished")); - assertEquals(clusterMessagesPublished, publisherStats.get("clusterMessagesPublished")); - - // Verify websocket stats - @SuppressWarnings("unchecked") - Map websocketStats = (Map) status.get("websocket"); - assertEquals(activeSessions, websocketStats.get("activeSessions")); - - } finally { - // No cleanup needed since we can't modify env vars in tests - } - - // Verify mocks were called - verify(subscriber).getStatus(); - verify(publisher).getMessagesPublished(); - verify(publisher).getClusterMessagesPublished(); - verify(broadcaster).getSessionCount(); - } + @Mock + private AeronSubscriberBean subscriber; + + @Mock + private MarketDataPublisher publisher; + + @Mock + private MarketDataBroadcaster broadcaster; + + @InjectMocks + private StatusResource statusResource; + + @Nested + @DisplayName("getStatus Method Tests") + class GetStatusMethodTests { + + @Test + @DisplayName("Should return complete status information") + void shouldReturnCompleteStatusInformation() { + // Arrange + String subscriberStatus = "Channel: aeron:ipc, Stream: 1001, Running: true"; + long localMessagesPublished = 5000L; + long clusterMessagesPublished = 25000L; + int activeSessions = 42; + String expectedInstanceName = "test-instance-1"; + + when(subscriber.getStatus()).thenReturn(subscriberStatus); + when(publisher.getMessagesPublished()).thenReturn(localMessagesPublished); + when(publisher.getClusterMessagesPublished()).thenReturn(clusterMessagesPublished); + when(broadcaster.getSessionCount()).thenReturn(activeSessions); + + // Set environment variable - note: tests can't modify real env vars, so we expect default + // behavior + // In this test, we expect "standalone" since we can't set actual environment variables + + try { + // Act + Response response = statusResource.getStatus(); + + // Assert + assertEquals(Response.Status.OK.getStatusCode(), response.getStatus()); + // Skip media type check as Response.getMediaType() may return null in tests + + @SuppressWarnings("unchecked") + Map status = (Map) response.getEntity(); + + // Verify required fields + assertEquals("TradeStreamEE", status.get("application")); + assertEquals("High-frequency trading dashboard with Aeron and SBE", status.get("description")); + assertEquals("standalone", status.get("instance")); // Default value + assertEquals(subscriberStatus, status.get("subscriber")); + assertEquals("UP", status.get("status")); + + // Verify publisher stats + @SuppressWarnings("unchecked") + Map publisherStats = (Map) status.get("publisher"); + assertEquals(localMessagesPublished, publisherStats.get("localMessagesPublished")); + assertEquals(clusterMessagesPublished, publisherStats.get("clusterMessagesPublished")); + + // Verify websocket stats + @SuppressWarnings("unchecked") + Map websocketStats = (Map) status.get("websocket"); + assertEquals(activeSessions, websocketStats.get("activeSessions")); + + } finally { + // No cleanup needed since we can't modify env vars in tests + } + + // Verify mocks were called + verify(subscriber).getStatus(); + verify(publisher).getMessagesPublished(); + verify(publisher).getClusterMessagesPublished(); + verify(broadcaster).getSessionCount(); + } - @Test - @DisplayName("Should use default instance name when environment variable is not set") - void shouldUseDefaultInstanceNameWhenEnvironmentVariableIsNotSet() { - // Arrange - when(subscriber.getStatus()).thenReturn("Running"); - when(publisher.getMessagesPublished()).thenReturn(1000L); - when(publisher.getClusterMessagesPublished()).thenReturn(5000L); - when(broadcaster.getSessionCount()).thenReturn(10); - - // Note: Tests can't modify real environment variables, so we expect default behavior - try { - // Act - Response response = statusResource.getStatus(); - - // Assert - @SuppressWarnings("unchecked") - Map status = (Map) response.getEntity(); - assertEquals("standalone", status.get("instance"), "Should use default instance name"); - - } finally { - // No cleanup needed - } - } + @Test + @DisplayName("Should use default instance name when environment variable is not set") + void shouldUseDefaultInstanceNameWhenEnvironmentVariableIsNotSet() { + // Arrange + when(subscriber.getStatus()).thenReturn("Running"); + when(publisher.getMessagesPublished()).thenReturn(1000L); + when(publisher.getClusterMessagesPublished()).thenReturn(5000L); + when(broadcaster.getSessionCount()).thenReturn(10); + + // Note: Tests can't modify real environment variables, so we expect default behavior + try { + // Act + Response response = statusResource.getStatus(); + + // Assert + @SuppressWarnings("unchecked") + Map status = (Map) response.getEntity(); + assertEquals("standalone", status.get("instance"), "Should use default instance name"); + + } finally { + // No cleanup needed + } + } - @Test - @DisplayName("Should handle null subscriber status gracefully") - void shouldHandleNullSubscriberStatusGracefully() { - // Arrange - when(subscriber.getStatus()).thenReturn(null); - when(publisher.getMessagesPublished()).thenReturn(1000L); - when(publisher.getClusterMessagesPublished()).thenReturn(5000L); - when(broadcaster.getSessionCount()).thenReturn(10); - - // Act - Response response = statusResource.getStatus(); - - // Assert - assertEquals(Response.Status.OK.getStatusCode(), response.getStatus()); - @SuppressWarnings("unchecked") - Map status = (Map) response.getEntity(); - assertNull(status.get("subscriber"), "Null subscriber status should be handled"); - } + @Test + @DisplayName("Should handle null subscriber status gracefully") + void shouldHandleNullSubscriberStatusGracefully() { + // Arrange + when(subscriber.getStatus()).thenReturn(null); + when(publisher.getMessagesPublished()).thenReturn(1000L); + when(publisher.getClusterMessagesPublished()).thenReturn(5000L); + when(broadcaster.getSessionCount()).thenReturn(10); + + // Act + Response response = statusResource.getStatus(); + + // Assert + assertEquals(Response.Status.OK.getStatusCode(), response.getStatus()); + @SuppressWarnings("unchecked") + Map status = (Map) response.getEntity(); + assertNull(status.get("subscriber"), "Null subscriber status should be handled"); + } - @Test - @DisplayName("Should handle zero values gracefully") - void shouldHandleZeroValuesGracefully() { - // Arrange - when(subscriber.getStatus()).thenReturn("Running"); - when(publisher.getMessagesPublished()).thenReturn(0L); - when(publisher.getClusterMessagesPublished()).thenReturn(0L); - when(broadcaster.getSessionCount()).thenReturn(0); - - // Act - Response response = statusResource.getStatus(); - - // Assert - assertEquals(Response.Status.OK.getStatusCode(), response.getStatus()); - @SuppressWarnings("unchecked") - Map status = (Map) response.getEntity(); - - @SuppressWarnings("unchecked") - Map publisherStats = (Map) status.get("publisher"); - assertEquals(0L, publisherStats.get("localMessagesPublished")); - assertEquals(0L, publisherStats.get("clusterMessagesPublished")); - - @SuppressWarnings("unchecked") - Map websocketStats = (Map) status.get("websocket"); - assertEquals(0, websocketStats.get("activeSessions")); - } + @Test + @DisplayName("Should handle zero values gracefully") + void shouldHandleZeroValuesGracefully() { + // Arrange + when(subscriber.getStatus()).thenReturn("Running"); + when(publisher.getMessagesPublished()).thenReturn(0L); + when(publisher.getClusterMessagesPublished()).thenReturn(0L); + when(broadcaster.getSessionCount()).thenReturn(0); + + // Act + Response response = statusResource.getStatus(); + + // Assert + assertEquals(Response.Status.OK.getStatusCode(), response.getStatus()); + @SuppressWarnings("unchecked") + Map status = (Map) response.getEntity(); + + @SuppressWarnings("unchecked") + Map publisherStats = (Map) status.get("publisher"); + assertEquals(0L, publisherStats.get("localMessagesPublished")); + assertEquals(0L, publisherStats.get("clusterMessagesPublished")); + + @SuppressWarnings("unchecked") + Map websocketStats = (Map) status.get("websocket"); + assertEquals(0, websocketStats.get("activeSessions")); + } - @Test - @DisplayName("Should handle very large values gracefully") - void shouldHandleVeryLargeValuesGracefully() { - // Arrange - long largeLocalMessages = Long.MAX_VALUE; - long largeClusterMessages = Long.MAX_VALUE - 1; - int largeSessions = Integer.MAX_VALUE - 1; // Leave room for additions - - when(subscriber.getStatus()).thenReturn("Running"); - when(publisher.getMessagesPublished()).thenReturn(largeLocalMessages); - when(publisher.getClusterMessagesPublished()).thenReturn(largeClusterMessages); - when(broadcaster.getSessionCount()).thenReturn(largeSessions); - - // Act - Response response = statusResource.getStatus(); - - // Assert - assertEquals(Response.Status.OK.getStatusCode(), response.getStatus()); - @SuppressWarnings("unchecked") - Map status = (Map) response.getEntity(); - - @SuppressWarnings("unchecked") - Map publisherStats = (Map) status.get("publisher"); - assertEquals(largeLocalMessages, publisherStats.get("localMessagesPublished")); - assertEquals(largeClusterMessages, publisherStats.get("clusterMessagesPublished")); - - @SuppressWarnings("unchecked") - Map websocketStats = (Map) status.get("websocket"); - assertEquals(largeSessions, websocketStats.get("activeSessions")); + @Test + @DisplayName("Should handle very large values gracefully") + void shouldHandleVeryLargeValuesGracefully() { + // Arrange + long largeLocalMessages = Long.MAX_VALUE; + long largeClusterMessages = Long.MAX_VALUE - 1; + int largeSessions = Integer.MAX_VALUE - 1; // Leave room for additions + + when(subscriber.getStatus()).thenReturn("Running"); + when(publisher.getMessagesPublished()).thenReturn(largeLocalMessages); + when(publisher.getClusterMessagesPublished()).thenReturn(largeClusterMessages); + when(broadcaster.getSessionCount()).thenReturn(largeSessions); + + // Act + Response response = statusResource.getStatus(); + + // Assert + assertEquals(Response.Status.OK.getStatusCode(), response.getStatus()); + @SuppressWarnings("unchecked") + Map status = (Map) response.getEntity(); + + @SuppressWarnings("unchecked") + Map publisherStats = (Map) status.get("publisher"); + assertEquals(largeLocalMessages, publisherStats.get("localMessagesPublished")); + assertEquals(largeClusterMessages, publisherStats.get("clusterMessagesPublished")); + + @SuppressWarnings("unchecked") + Map websocketStats = (Map) status.get("websocket"); + assertEquals(largeSessions, websocketStats.get("activeSessions")); + } } - } - @Nested - @DisplayName("getClusterStatus Method Tests") - class GetClusterStatusMethodTests { + @Nested + @DisplayName("getClusterStatus Method Tests") + class GetClusterStatusMethodTests { - @Test - @DisplayName("Should return standalone status when Hazelcast is null") - void shouldReturnStandaloneStatusWhenHazelcastIsNull() { - // Act - hazelcastInstance is injected as null in this test class + @Test + @DisplayName("Should return standalone status when Hazelcast is null") + void shouldReturnStandaloneStatusWhenHazelcastIsNull() { + // Act - hazelcastInstance is injected as null in this test class - // Act - Response response = statusResource.getClusterStatus(); + // Act + Response response = statusResource.getClusterStatus(); - // Assert - assertEquals(Response.Status.OK.getStatusCode(), response.getStatus()); - @SuppressWarnings("unchecked") - Map clusterInfo = (Map) response.getEntity(); + // Assert + assertEquals(Response.Status.OK.getStatusCode(), response.getStatus()); + @SuppressWarnings("unchecked") + Map clusterInfo = (Map) response.getEntity(); - assertFalse((Boolean) clusterInfo.get("clustered")); - assertEquals( - "Running in standalone mode (Hazelcast not available)", clusterInfo.get("message")); - } - } - - @Nested - @DisplayName("Dependency Injection Tests") - class DependencyInjectionTests { - - @Test - @DisplayName("Should properly inject all dependencies") - void shouldProperlyInjectAllDependencies() { - // Test that all mocks are properly injected - assertNotNull(subscriber, "Subscriber should be injected"); - assertNotNull(publisher, "Publisher should be injected"); - assertNotNull(broadcaster, "Broadcaster should be injected"); - assertNotNull(statusResource, "StatusResource should be created"); - } - } - - @Nested - @DisplayName("Response Format Tests") - class ResponseFormatTests { - - @Test - @DisplayName("Should always return JSON response") - void shouldAlwaysReturnJSONResponse() { - // Arrange - when(subscriber.getStatus()).thenReturn("Running"); - when(publisher.getMessagesPublished()).thenReturn(1000L); - when(publisher.getClusterMessagesPublished()).thenReturn(5000L); - when(broadcaster.getSessionCount()).thenReturn(10); - - // Act - Response statusResponse = statusResource.getStatus(); - Response clusterResponse = statusResource.getClusterStatus(); - - // Assert - skip media type checks as they may return null in tests - assertNotNull(statusResponse, "Status response should not be null"); - assertNotNull(clusterResponse, "Cluster response should not be null"); + assertFalse((Boolean) clusterInfo.get("clustered")); + assertEquals("Running in standalone mode (Hazelcast not available)", clusterInfo.get("message")); + } } - @Test - @DisplayName("Should always return success status codes for normal operations") - void shouldAlwaysReturnSuccessStatusCodesForNormalOperations() { - // Arrange - when(subscriber.getStatus()).thenReturn("Running"); - when(publisher.getMessagesPublished()).thenReturn(1000L); - when(publisher.getClusterMessagesPublished()).thenReturn(5000L); - when(broadcaster.getSessionCount()).thenReturn(10); - - // Act - Response statusResponse = statusResource.getStatus(); - Response clusterResponse = statusResource.getClusterStatus(); - - // Assert - assertEquals(Response.Status.OK.getStatusCode(), statusResponse.getStatus()); - assertEquals(Response.Status.OK.getStatusCode(), clusterResponse.getStatus()); + @Nested + @DisplayName("Dependency Injection Tests") + class DependencyInjectionTests { + + @Test + @DisplayName("Should properly inject all dependencies") + void shouldProperlyInjectAllDependencies() { + // Test that all mocks are properly injected + assertNotNull(subscriber, "Subscriber should be injected"); + assertNotNull(publisher, "Publisher should be injected"); + assertNotNull(broadcaster, "Broadcaster should be injected"); + assertNotNull(statusResource, "StatusResource should be created"); + } } - } - - @Nested - @DisplayName("Environment Variable Tests") - class EnvironmentVariableTests { - - @Test - @DisplayName("Should handle different instance name environment variables") - void shouldHandleDifferentInstanceNameEnvironmentVariables() { - String[] testNames = { - "", " ", "\tinstance-1", "instance-1", "test-instance-123", "production-instance" - }; - - for (String instanceName : testNames) { - // Arrange - when(subscriber.getStatus()).thenReturn("Running"); - when(publisher.getMessagesPublished()).thenReturn(1000L); - when(publisher.getClusterMessagesPublished()).thenReturn(5000L); - when(broadcaster.getSessionCount()).thenReturn(10); - - // Note: Tests can't modify real environment variables, so we always expect "standalone" - try { - // Act - Response response = statusResource.getStatus(); - - // Assert - @SuppressWarnings("unchecked") - Map status = (Map) response.getEntity(); - assertEquals("standalone", status.get("instance")); - - } finally { - // No cleanup needed + + @Nested + @DisplayName("Response Format Tests") + class ResponseFormatTests { + + @Test + @DisplayName("Should always return JSON response") + void shouldAlwaysReturnJSONResponse() { + // Arrange + when(subscriber.getStatus()).thenReturn("Running"); + when(publisher.getMessagesPublished()).thenReturn(1000L); + when(publisher.getClusterMessagesPublished()).thenReturn(5000L); + when(broadcaster.getSessionCount()).thenReturn(10); + + // Act + Response statusResponse = statusResource.getStatus(); + Response clusterResponse = statusResource.getClusterStatus(); + + // Assert - skip media type checks as they may return null in tests + assertNotNull(statusResponse, "Status response should not be null"); + assertNotNull(clusterResponse, "Cluster response should not be null"); + } + + @Test + @DisplayName("Should always return success status codes for normal operations") + void shouldAlwaysReturnSuccessStatusCodesForNormalOperations() { + // Arrange + when(subscriber.getStatus()).thenReturn("Running"); + when(publisher.getMessagesPublished()).thenReturn(1000L); + when(publisher.getClusterMessagesPublished()).thenReturn(5000L); + when(broadcaster.getSessionCount()).thenReturn(10); + + // Act + Response statusResponse = statusResource.getStatus(); + Response clusterResponse = statusResource.getClusterStatus(); + + // Assert + assertEquals(Response.Status.OK.getStatusCode(), statusResponse.getStatus()); + assertEquals(Response.Status.OK.getStatusCode(), clusterResponse.getStatus()); } - } } - } - - @Nested - @DisplayName("Edge Cases Tests") - class EdgeCasesTests { - - @Test - @DisplayName("Should handle multiple rapid calls correctly") - void shouldHandleMultipleRapidCallsCorrectly() { - // Arrange - when(subscriber.getStatus()).thenReturn("Running"); - when(publisher.getMessagesPublished()).thenReturn(1000L, 2000L, 3000L); - when(publisher.getClusterMessagesPublished()).thenReturn(5000L, 10000L, 15000L); - when(broadcaster.getSessionCount()).thenReturn(10, 20, 30); - - // Act - Response response1 = statusResource.getStatus(); - Response response2 = statusResource.getStatus(); - Response response3 = statusResource.getStatus(); - - // Assert - All calls should succeed - assertEquals(Response.Status.OK.getStatusCode(), response1.getStatus()); - assertEquals(Response.Status.OK.getStatusCode(), response2.getStatus()); - assertEquals(Response.Status.OK.getStatusCode(), response3.getStatus()); - - // Verify mocks were called correct number of times - verify(publisher, times(3)).getMessagesPublished(); - verify(publisher, times(3)).getClusterMessagesPublished(); + + @Nested + @DisplayName("Environment Variable Tests") + class EnvironmentVariableTests { + + @Test + @DisplayName("Should handle different instance name environment variables") + void shouldHandleDifferentInstanceNameEnvironmentVariables() { + String[] testNames = {"", " ", "\tinstance-1", "instance-1", "test-instance-123", "production-instance"}; + + for (String instanceName : testNames) { + // Arrange + when(subscriber.getStatus()).thenReturn("Running"); + when(publisher.getMessagesPublished()).thenReturn(1000L); + when(publisher.getClusterMessagesPublished()).thenReturn(5000L); + when(broadcaster.getSessionCount()).thenReturn(10); + + // Note: Tests can't modify real environment variables, so we always expect "standalone" + try { + // Act + Response response = statusResource.getStatus(); + + // Assert + @SuppressWarnings("unchecked") + Map status = (Map) response.getEntity(); + assertEquals("standalone", status.get("instance")); + + } finally { + // No cleanup needed + } + } + } } - @Test - @DisplayName("Should handle concurrent access safely") - void shouldHandleConcurrentAccessSafely() throws InterruptedException { - // Arrange - when(subscriber.getStatus()).thenReturn("Running"); - when(publisher.getMessagesPublished()).thenReturn(1000L); - when(publisher.getClusterMessagesPublished()).thenReturn(5000L); - when(broadcaster.getSessionCount()).thenReturn(10); - - int threadCount = 10; - Thread[] threads = new Thread[threadCount]; - Response[] responses = new Response[threadCount]; - - // Act - for (int i = 0; i < threadCount; i++) { - final int index = i; - threads[i] = - new Thread( - () -> { - responses[index] = statusResource.getStatus(); + @Nested + @DisplayName("Edge Cases Tests") + class EdgeCasesTests { + + @Test + @DisplayName("Should handle multiple rapid calls correctly") + void shouldHandleMultipleRapidCallsCorrectly() { + // Arrange + when(subscriber.getStatus()).thenReturn("Running"); + when(publisher.getMessagesPublished()).thenReturn(1000L, 2000L, 3000L); + when(publisher.getClusterMessagesPublished()).thenReturn(5000L, 10000L, 15000L); + when(broadcaster.getSessionCount()).thenReturn(10, 20, 30); + + // Act + Response response1 = statusResource.getStatus(); + Response response2 = statusResource.getStatus(); + Response response3 = statusResource.getStatus(); + + // Assert - All calls should succeed + assertEquals(Response.Status.OK.getStatusCode(), response1.getStatus()); + assertEquals(Response.Status.OK.getStatusCode(), response2.getStatus()); + assertEquals(Response.Status.OK.getStatusCode(), response3.getStatus()); + + // Verify mocks were called correct number of times + verify(publisher, times(3)).getMessagesPublished(); + verify(publisher, times(3)).getClusterMessagesPublished(); + } + + @Test + @DisplayName("Should handle concurrent access safely") + void shouldHandleConcurrentAccessSafely() throws InterruptedException { + // Arrange + when(subscriber.getStatus()).thenReturn("Running"); + when(publisher.getMessagesPublished()).thenReturn(1000L); + when(publisher.getClusterMessagesPublished()).thenReturn(5000L); + when(broadcaster.getSessionCount()).thenReturn(10); + + int threadCount = 10; + Thread[] threads = new Thread[threadCount]; + Response[] responses = new Response[threadCount]; + + // Act + for (int i = 0; i < threadCount; i++) { + final int index = i; + threads[i] = new Thread(() -> { + responses[index] = statusResource.getStatus(); }); - threads[i].start(); - } - - // Wait for all threads to complete - for (Thread thread : threads) { - thread.join(5000); - } - - // Assert - All responses should be successful - for (Response response : responses) { - assertNotNull(response, "Response should not be null"); - assertEquals( - Response.Status.OK.getStatusCode(), - response.getStatus(), - "All responses should be successful"); - } + threads[i].start(); + } + + // Wait for all threads to complete + for (Thread thread : threads) { + thread.join(5000); + } + + // Assert - All responses should be successful + for (Response response : responses) { + assertNotNull(response, "Response should not be null"); + assertEquals(Response.Status.OK.getStatusCode(), response.getStatus(), "All responses should be successful"); + } + } } - } } diff --git a/src/test/java/fish/payara/trader/utils/GCTestUtil.java b/src/test/java/fish/payara/trader/utils/GCTestUtil.java index 6a8679b..ac14a77 100644 --- a/src/test/java/fish/payara/trader/utils/GCTestUtil.java +++ b/src/test/java/fish/payara/trader/utils/GCTestUtil.java @@ -11,193 +11,189 @@ /** Utility class for GC testing and simulation */ public class GCTestUtil { - private static final MemoryMXBean memoryBean = ManagementFactory.getMemoryMXBean(); - private static final List gcBeans = - ManagementFactory.getGarbageCollectorMXBeans(); - - /** Captures initial GC statistics */ - public static GCStatistics captureInitialStats() { - long totalCollections = 0; - long totalTime = 0; - - for (GarbageCollectorMXBean gcBean : gcBeans) { - totalCollections += gcBean.getCollectionCount(); - totalTime += gcBean.getCollectionTime(); - } + private static final MemoryMXBean memoryBean = ManagementFactory.getMemoryMXBean(); + private static final List gcBeans = ManagementFactory.getGarbageCollectorMXBeans(); - return new GCStatistics(totalCollections, totalTime, getHeapUsage()); - } + /** Captures initial GC statistics */ + public static GCStatistics captureInitialStats() { + long totalCollections = 0; + long totalTime = 0; - /** Calculates GC statistics after a test */ - public static GCStatistics calculateDelta(GCStatistics initial) { - long totalCollections = 0; - long totalTime = 0; + for (GarbageCollectorMXBean gcBean : gcBeans) { + totalCollections += gcBean.getCollectionCount(); + totalTime += gcBean.getCollectionTime(); + } - for (GarbageCollectorMXBean gcBean : gcBeans) { - totalCollections += gcBean.getCollectionCount(); - totalTime += gcBean.getCollectionTime(); + return new GCStatistics(totalCollections, totalTime, getHeapUsage()); } - return new GCStatistics( - totalCollections - initial.collections, totalTime - initial.time, getHeapUsage()); - } + /** Calculates GC statistics after a test */ + public static GCStatistics calculateDelta(GCStatistics initial) { + long totalCollections = 0; + long totalTime = 0; - /** Gets current heap usage */ - public static MemoryUsage getHeapUsage() { - return memoryBean.getHeapMemoryUsage(); - } + for (GarbageCollectorMXBean gcBean : gcBeans) { + totalCollections += gcBean.getCollectionCount(); + totalTime += gcBean.getCollectionTime(); + } - /** Allocates memory to trigger GC */ - public static void allocateMemory(int sizeMB) { - allocateMemory(sizeMB, 10); - } + return new GCStatistics(totalCollections - initial.collections, totalTime - initial.time, getHeapUsage()); + } - /** Allocates memory with controlled pattern */ - public static void allocateMemory(int sizeMB, int chunks) { - int chunkSize = (sizeMB * 1024 * 1024) / chunks; - List allocations = new ArrayList<>(); + /** Gets current heap usage */ + public static MemoryUsage getHeapUsage() { + return memoryBean.getHeapMemoryUsage(); + } + + /** Allocates memory to trigger GC */ + public static void allocateMemory(int sizeMB) { + allocateMemory(sizeMB, 10); + } - try { - for (int i = 0; i < chunks; i++) { - allocations.add(new byte[chunkSize]); + /** Allocates memory with controlled pattern */ + public static void allocateMemory(int sizeMB, int chunks) { + int chunkSize = (sizeMB * 1024 * 1024) / chunks; + List allocations = new ArrayList<>(); - // Small delay to simulate real usage pattern - if (i % 10 == 0) { - Thread.yield(); + try { + for (int i = 0; i < chunks; i++) { + allocations.add(new byte[chunkSize]); + + // Small delay to simulate real usage pattern + if (i % 10 == 0) { + Thread.yield(); + } + } + } finally { + // Let allocations go out of scope to trigger GC + allocations.clear(); } - } - } finally { - // Let allocations go out of scope to trigger GC - allocations.clear(); } - } - - /** Creates memory pressure in background thread */ - public static MemoryPressureThread createMemoryPressure( - int allocationRateMB, int durationSeconds) { - MemoryPressureThread pressureThread = - new MemoryPressureThread(allocationRateMB, durationSeconds); - return pressureThread; - } - - /** Forces a garbage collection and waits for completion */ - public static void forceGC() { - System.gc(); - System.runFinalization(); - - // Small delay to allow GC to complete - try { - Thread.sleep(100); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); + + /** Creates memory pressure in background thread */ + public static MemoryPressureThread createMemoryPressure(int allocationRateMB, int durationSeconds) { + MemoryPressureThread pressureThread = new MemoryPressureThread(allocationRateMB, durationSeconds); + return pressureThread; } - } - /** Measures GC pause time for a specific operation */ - public static long measurePauseTime(Runnable operation) { - GCStatistics before = captureInitialStats(); + /** Forces a garbage collection and waits for completion */ + public static void forceGC() { + System.gc(); + System.runFinalization(); - long startTime = System.nanoTime(); - operation.run(); - long endTime = System.nanoTime(); + // Small delay to allow GC to complete + try { + Thread.sleep(100); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } - GCStatistics after = calculateDelta(before); + /** Measures GC pause time for a specific operation */ + public static long measurePauseTime(Runnable operation) { + GCStatistics before = captureInitialStats(); - return after.time; - } + long startTime = System.nanoTime(); + operation.run(); + long endTime = System.nanoTime(); - /** GC Statistics holder */ - public static class GCStatistics { - public final long collections; - public final long time; - public final MemoryUsage heapUsage; + GCStatistics after = calculateDelta(before); - public GCStatistics(long collections, long time, MemoryUsage heapUsage) { - this.collections = collections; - this.time = time; - this.heapUsage = heapUsage; + return after.time; } - @Override - public String toString() { - return String.format("GC{collections=%d, time=%dms, heap=%s}", collections, time, heapUsage); - } - } - - /** Background thread for creating controlled memory pressure */ - public static class MemoryPressureThread extends Thread { - private final int allocationRateMB; - private final int durationSeconds; - private final AtomicLong allocatedBytes = new AtomicLong(0); - private volatile boolean running = true; - - public MemoryPressureThread(int allocationRateMB, int durationSeconds) { - this.allocationRateMB = allocationRateMB; - this.durationSeconds = durationSeconds; - setDaemon(true); - setName("MemoryPressureThread"); - } + /** GC Statistics holder */ + public static class GCStatistics { + public final long collections; + public final long time; + public final MemoryUsage heapUsage; - @Override - public void run() { - long startTime = System.currentTimeMillis(); - long endTime = startTime + (durationSeconds * 1000L); + public GCStatistics(long collections, long time, MemoryUsage heapUsage) { + this.collections = collections; + this.time = time; + this.heapUsage = heapUsage; + } - int chunkSize = 1024 * 1024; // 1MB chunks - int allocationsPerSecond = allocationRateMB; - long allocationInterval = 1000L / allocationsPerSecond; // milliseconds + @Override + public String toString() { + return String.format("GC{collections=%d, time=%dms, heap=%s}", collections, time, heapUsage); + } + } - List allocations = new ArrayList<>(); + /** Background thread for creating controlled memory pressure */ + public static class MemoryPressureThread extends Thread { + private final int allocationRateMB; + private final int durationSeconds; + private final AtomicLong allocatedBytes = new AtomicLong(0); + private volatile boolean running = true; + + public MemoryPressureThread(int allocationRateMB, int durationSeconds) { + this.allocationRateMB = allocationRateMB; + this.durationSeconds = durationSeconds; + setDaemon(true); + setName("MemoryPressureThread"); + } - while (running && System.currentTimeMillis() < endTime) { - try { - allocations.add(new byte[chunkSize]); - allocatedBytes.addAndGet(chunkSize); + @Override + public void run() { + long startTime = System.currentTimeMillis(); + long endTime = startTime + (durationSeconds * 1000L); + + int chunkSize = 1024 * 1024; // 1MB chunks + int allocationsPerSecond = allocationRateMB; + long allocationInterval = 1000L / allocationsPerSecond; // milliseconds + + List allocations = new ArrayList<>(); + + while (running && System.currentTimeMillis() < endTime) { + try { + allocations.add(new byte[chunkSize]); + allocatedBytes.addAndGet(chunkSize); + + // Control allocation rate + if (allocationInterval > 0) { + Thread.sleep(allocationInterval); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + break; + } + } + + // Clear allocations to trigger GC + allocations.clear(); + allocatedBytes.set(0); + } - // Control allocation rate - if (allocationInterval > 0) { - Thread.sleep(allocationInterval); - } - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - break; + public void stopPressure() { + running = false; + interrupt(); } - } - // Clear allocations to trigger GC - allocations.clear(); - allocatedBytes.set(0); + public long getAllocatedBytes() { + return allocatedBytes.get(); + } } - public void stopPressure() { - running = false; - interrupt(); - } + /** Wait for GC to occur within timeout */ + public static boolean waitForGC(long timeoutMillis) { + GCStatistics initial = captureInitialStats(); + long startTime = System.currentTimeMillis(); + + while (System.currentTimeMillis() - startTime < timeoutMillis) { + GCStatistics current = calculateDelta(initial); + if (current.collections > 0) { + return true; + } + + try { + Thread.sleep(10); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + break; + } + } - public long getAllocatedBytes() { - return allocatedBytes.get(); - } - } - - /** Wait for GC to occur within timeout */ - public static boolean waitForGC(long timeoutMillis) { - GCStatistics initial = captureInitialStats(); - long startTime = System.currentTimeMillis(); - - while (System.currentTimeMillis() - startTime < timeoutMillis) { - GCStatistics current = calculateDelta(initial); - if (current.collections > 0) { - return true; - } - - try { - Thread.sleep(10); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - break; - } + return false; } - - return false; - } } diff --git a/src/test/java/fish/payara/trader/websocket/MarketDataBroadcasterTest.java b/src/test/java/fish/payara/trader/websocket/MarketDataBroadcasterTest.java index c6ffa35..e50511d 100644 --- a/src/test/java/fish/payara/trader/websocket/MarketDataBroadcasterTest.java +++ b/src/test/java/fish/payara/trader/websocket/MarketDataBroadcasterTest.java @@ -18,140 +18,142 @@ @ExtendWith(MockitoExtension.class) class MarketDataBroadcasterTest { - private MarketDataBroadcaster broadcaster; + private MarketDataBroadcaster broadcaster; - @Mock private Session session1; + @Mock + private Session session1; - @Mock private Session session2; + @Mock + private Session session2; - @Mock private Async asyncRemote1; + @Mock + private Async asyncRemote1; - @Mock private Async asyncRemote2; + @Mock + private Async asyncRemote2; - @BeforeEach - void setUp() { - broadcaster = new MarketDataBroadcaster(); - lenient().when(session1.getId()).thenReturn("session-1"); - lenient().when(session2.getId()).thenReturn("session-2"); - lenient().when(session1.getAsyncRemote()).thenReturn(asyncRemote1); - lenient().when(session2.getAsyncRemote()).thenReturn(asyncRemote2); - lenient().when(session1.isOpen()).thenReturn(true); - lenient().when(session2.isOpen()).thenReturn(true); - } + @BeforeEach + void setUp() { + broadcaster = new MarketDataBroadcaster(); + lenient().when(session1.getId()).thenReturn("session-1"); + lenient().when(session2.getId()).thenReturn("session-2"); + lenient().when(session1.getAsyncRemote()).thenReturn(asyncRemote1); + lenient().when(session2.getAsyncRemote()).thenReturn(asyncRemote2); + lenient().when(session1.isOpen()).thenReturn(true); + lenient().when(session2.isOpen()).thenReturn(true); + } - @Test - void testAddSession() { - broadcaster.addSession(session1); + @Test + void testAddSession() { + broadcaster.addSession(session1); - assertThat(broadcaster.getSessionCount()).isEqualTo(1); - } + assertThat(broadcaster.getSessionCount()).isEqualTo(1); + } - @Test - void testAddMultipleSessions() { - broadcaster.addSession(session1); - broadcaster.addSession(session2); + @Test + void testAddMultipleSessions() { + broadcaster.addSession(session1); + broadcaster.addSession(session2); - assertThat(broadcaster.getSessionCount()).isEqualTo(2); - } + assertThat(broadcaster.getSessionCount()).isEqualTo(2); + } - @Test - void testRemoveSession() { - broadcaster.addSession(session1); - broadcaster.addSession(session2); + @Test + void testRemoveSession() { + broadcaster.addSession(session1); + broadcaster.addSession(session2); - broadcaster.removeSession(session1); + broadcaster.removeSession(session1); - assertThat(broadcaster.getSessionCount()).isEqualTo(1); - } + assertThat(broadcaster.getSessionCount()).isEqualTo(1); + } - @Test - void testBroadcastToAllSessions() { - broadcaster.addSession(session1); - broadcaster.addSession(session2); + @Test + void testBroadcastToAllSessions() { + broadcaster.addSession(session1); + broadcaster.addSession(session2); - String message = "{\"type\":\"trade\",\"price\":100}"; - broadcaster.broadcast(message); + String message = "{\"type\":\"trade\",\"price\":100}"; + broadcaster.broadcast(message); - verify(asyncRemote1).sendText(eq(message)); - verify(asyncRemote2).sendText(eq(message)); - } + verify(asyncRemote1).sendText(eq(message)); + verify(asyncRemote2).sendText(eq(message)); + } - @Test - void testBroadcastRemovesClosedSessions() { - broadcaster.addSession(session1); - broadcaster.addSession(session2); + @Test + void testBroadcastRemovesClosedSessions() { + broadcaster.addSession(session1); + broadcaster.addSession(session2); - when(session1.isOpen()).thenReturn(false); + when(session1.isOpen()).thenReturn(false); - String message = "{\"type\":\"trade\",\"price\":100}"; - broadcaster.broadcast(message); + String message = "{\"type\":\"trade\",\"price\":100}"; + broadcaster.broadcast(message); - verify(asyncRemote1, never()).sendText(any()); - verify(asyncRemote2).sendText(eq(message)); + verify(asyncRemote1, never()).sendText(any()); + verify(asyncRemote2).sendText(eq(message)); - assertThat(broadcaster.getSessionCount()).isEqualTo(1); - } + assertThat(broadcaster.getSessionCount()).isEqualTo(1); + } - @Test - void testBroadcastHandlesFailure() { - broadcaster.addSession(session1); - broadcaster.addSession(session2); + @Test + void testBroadcastHandlesFailure() { + broadcaster.addSession(session1); + broadcaster.addSession(session2); - String message = "{\"type\":\"trade\",\"price\":100}"; + String message = "{\"type\":\"trade\",\"price\":100}"; - doThrow(new RuntimeException("Send failed")).when(asyncRemote1).sendText(message); + doThrow(new RuntimeException("Send failed")).when(asyncRemote1).sendText(message); - broadcaster.broadcast(message); + broadcaster.broadcast(message); - verify(asyncRemote2).sendText(eq(message)); - assertThat(broadcaster.getSessionCount()).isEqualTo(1); - } + verify(asyncRemote2).sendText(eq(message)); + assertThat(broadcaster.getSessionCount()).isEqualTo(1); + } - @Test - void testGetSessionCountWhenEmpty() { - assertThat(broadcaster.getSessionCount()).isEqualTo(0); - } + @Test + void testGetSessionCountWhenEmpty() { + assertThat(broadcaster.getSessionCount()).isEqualTo(0); + } - @Test - void testBroadcastWithArtificialLoad() { - broadcaster.addSession(session1); + @Test + void testBroadcastWithArtificialLoad() { + broadcaster.addSession(session1); - String baseMessage = "{\"type\":\"trade\",\"price\":100}"; - broadcaster.broadcastWithArtificialLoad(baseMessage); + String baseMessage = "{\"type\":\"trade\",\"price\":100}"; + broadcaster.broadcastWithArtificialLoad(baseMessage); - ArgumentCaptor messageCaptor = ArgumentCaptor.forClass(String.class); - verify(asyncRemote1).sendText(messageCaptor.capture()); + ArgumentCaptor messageCaptor = ArgumentCaptor.forClass(String.class); + verify(asyncRemote1).sendText(messageCaptor.capture()); - String sentMessage = messageCaptor.getValue(); - assertThat(sentMessage).contains(baseMessage); - assertThat(sentMessage).contains("\"wrapped\""); - assertThat(sentMessage).contains("\"padding\""); - assertThat(sentMessage.length()).isGreaterThan(baseMessage.length()); - } + String sentMessage = messageCaptor.getValue(); + assertThat(sentMessage).contains(baseMessage); + assertThat(sentMessage).contains("\"wrapped\""); + assertThat(sentMessage).contains("\"padding\""); + assertThat(sentMessage.length()).isGreaterThan(baseMessage.length()); + } - @Test - void testConcurrentSessionManagement() throws InterruptedException { - int threadCount = 10; - Thread[] threads = new Thread[threadCount]; + @Test + void testConcurrentSessionManagement() throws InterruptedException { + int threadCount = 10; + Thread[] threads = new Thread[threadCount]; - for (int i = 0; i < threadCount; i++) { - final int index = i; - threads[i] = - new Thread( - () -> { + for (int i = 0; i < threadCount; i++) { + final int index = i; + threads[i] = new Thread(() -> { Session mockSession = mock(Session.class); when(mockSession.getId()).thenReturn("session-" + index); when(mockSession.getAsyncRemote()).thenReturn(mock(Async.class)); when(mockSession.isOpen()).thenReturn(true); broadcaster.addSession(mockSession); - }); - threads[i].start(); - } + }); + threads[i].start(); + } - for (Thread thread : threads) { - thread.join(); - } + for (Thread thread : threads) { + thread.join(); + } - assertThat(broadcaster.getSessionCount()).isEqualTo(threadCount); - } + assertThat(broadcaster.getSessionCount()).isEqualTo(threadCount); + } }