Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 5 additions & 17 deletions src/main/java/com/volta/Main.java
Original file line number Diff line number Diff line change
@@ -1,26 +1,14 @@
package com.volta;

import java.net.http.HttpResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.volta.engine.LoadEngine;

public class Main {
private static final Logger log = LoggerFactory.getLogger(Main.class);
private static final String TARGET_URL = "https://jsonplaceholder.typicode.com/posts/1";
private static final int TARGET_RPS = 5;
private static final int DURATION_SECONDS = 10;

public static void main(String[] args) {
try (HttpSender sender = new HttpSender()) {
while (true) {
try {
Thread.sleep(1000);
HttpResponse<String> response = sender.send(TARGET_URL);
log.info("Status: {}, Body: {}", response.statusCode(), response.body());
} catch (Exception e) {
log.error("Request failed", e);
}
}
} catch (Exception e) {
log.error("Sender closed or failed to initialize", e);
}
LoadEngine engine = new LoadEngine(TARGET_URL, TARGET_RPS, DURATION_SECONDS);
engine.start();
}
}
63 changes: 63 additions & 0 deletions src/main/java/com/volta/engine/LoadEngine.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package com.volta.engine;

import com.volta.http.HttpSender;
import java.net.http.HttpResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class LoadEngine {
private static final Logger log = LoggerFactory.getLogger(LoadEngine.class);

private final String url;
private final int targetRps;
private final int durationSeconds;
private volatile boolean running = false;

public LoadEngine(String URL, int targetRPS, int durationSeconds) {
if (URL == null || URL.isBlank()) {
throw new IllegalArgumentException("URL must not be empty");
}
if (targetRPS <= 0) {
throw new IllegalArgumentException("RPS must be positive");
}
if (durationSeconds <= 0) {
throw new IllegalArgumentException("Duration must be positive");
}

this.url = URL;
this.targetRps = targetRPS;
this.durationSeconds = durationSeconds;
Comment on lines +28 to +29
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

validate values

}

public void start() {
running = true;
long intervalNanos = 1_000_000_000L / targetRps;
long endTime = System.nanoTime() + (long) durationSeconds * 1_000_000_000L;
long sendNextTime = System.nanoTime();

try (HttpSender sender = new HttpSender()) {
while (running && System.nanoTime() < endTime) {

while (System.nanoTime() < sendNextTime) {
// busy-wait
}
Comment on lines +41 to +43
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thread.sleep?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At high RPS sleep precision can become a problem — we can overshoot the interval. Should I combine both approaches (sleep for most of the wait, then busy-wait for the last ~1ms), or is Thread.sleep() acceptable for now?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thread.sleep is a simple way, but there are other delay options
Using a loop doesn’t look very reliable to me


try {
HttpResponse<String> response = sender.send(url);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use asynchronous sending (or a thread pool). Otherwise, the calls may block and the required RPC performance cannot be achieved

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a limitation of the current implementation. The next issue (#10 ) introduces Virtual Threads — each request will be submitted to a Virtual Thread pool so the main loop is not blocked.

log.info("Status: {}, Body: {}", response.statusCode(), response.body());
} catch (Exception e) {
log.error("Request failed", e);
}
sendNextTime += intervalNanos;
}
} catch (Exception e) {
log.error("Sender closed or failed to initialize", e);
}

log.info("Test finished");
}

public void stop() {
running = false;
}
}
22 changes: 22 additions & 0 deletions src/main/java/com/volta/http/HttpSender.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.volta.http;

import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;

public class HttpSender implements AutoCloseable {
private final HttpClient client = HttpClient.newHttpClient();

public HttpResponse<String> send(String url) throws IOException, InterruptedException {
HttpRequest request = HttpRequest.newBuilder().uri(URI.create(url)).GET().build();

return client.send(request, HttpResponse.BodyHandlers.ofString());
}

@Override
public void close() throws Exception {
client.close();
}
}
104 changes: 104 additions & 0 deletions src/test/java/com/volta/engine/LoadEngineTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
package com.volta.engine;

import static com.github.tomakehurst.wiremock.client.WireMock.*;
import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig;
import static org.junit.jupiter.api.Assertions.*;

import com.github.tomakehurst.wiremock.WireMockServer;
import com.volta.http.HttpSender;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

class LoadEngineTest {

private WireMockServer wireMock;
private String baseUrl;
private HttpSender sender;

@BeforeEach
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

add AfterEach for closing sender

void setUp() {
wireMock = new WireMockServer(wireMockConfig().dynamicPort());
wireMock.start();
wireMock.stubFor(get("/test").willReturn(ok("OK")));
baseUrl = "http://localhost:" + wireMock.port();

try (HttpSender sender = new HttpSender()) {
sender.send(baseUrl + "/test");
Comment on lines +26 to +27
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this needed?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HttpClient warmup. The first request takes 2-3 seconds due to internal initialization. Without this RPS accuracy tests fail because most of the test duration is spent on the cold start.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In that case, it should be in the source code, not in the test code

} catch (Exception ignored) {
}
}

@AfterEach
void tearDown() throws Exception {
if (sender != null) {
sender.close();
}
wireMock.stop();
}

@Test
void shouldSendRequestsAtTargetRps() {
int targetRps = 10;
int durationSeconds = 5;
int expectedRequests = targetRps * durationSeconds;

LoadEngine engine = new LoadEngine(baseUrl + "/test", targetRps, durationSeconds);
engine.start();

int actualRequests =
wireMock.countRequestsMatching(getRequestedFor(urlEqualTo("/test")).build()).getCount();

int minExpected = (int) (expectedRequests * 0.8);
int maxExpected = (int) (expectedRequests * 1.2);

assertTrue(
actualRequests >= minExpected && actualRequests <= maxExpected,
String.format(
"Expected %d requests (±20%%), but got %d", expectedRequests, actualRequests));
}

@Test
void shouldStopAutomaticallyAfterDuration() {
int durationSeconds = 2;
LoadEngine engine = new LoadEngine(baseUrl + "/test", 10, durationSeconds);

long startTime = System.currentTimeMillis();
engine.start();
long elapsed = System.currentTimeMillis() - startTime;

assertTrue(
elapsed >= 2000 && elapsed < 3000,
String.format("Expected ~2000ms, but took %dms", elapsed));
}

@Test
void shouldStopManually() throws InterruptedException {
int durationSeconds = 60;
LoadEngine engine = new LoadEngine(baseUrl + "/test", 10, durationSeconds);

Thread engineThread = new Thread(engine::start);
engineThread.start();

Thread.sleep(1000);
engine.stop();
engineThread.join(2000);

assertFalse(engineThread.isAlive(), "Engine should have stopped");
}

@Test
void shouldHandleServerErrors() {
wireMock.stubFor(get("/error").willReturn(serverError()));
LoadEngine engine = new LoadEngine(baseUrl + "/error", 10, 2);

assertDoesNotThrow(() -> engine.start());
}

@Test
void shouldHandleInvalidUrl() {
LoadEngine engine = new LoadEngine("http://invalid-host-that-does-not-exist:9999/test", 5, 2);

assertDoesNotThrow(() -> engine.start());
}
}
35 changes: 35 additions & 0 deletions src/test/java/com/volta/http/HttpSenderTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package com.volta.http;

import static com.github.tomakehurst.wiremock.client.WireMock.*;
import static org.junit.jupiter.api.Assertions.*;

import com.github.tomakehurst.wiremock.junit5.WireMockTest;
import java.net.http.HttpResponse;
import org.junit.jupiter.api.Test;

@WireMockTest(httpPort = 8089)
class HttpSenderTest {

private final HttpSender sender = new HttpSender();

@Test
void sendReturns200ForValidUrl() throws Exception {
stubFor(get("/test").willReturn(ok("Hello from mock!")));

HttpResponse<String> response = sender.send("http://localhost:8089/test");
assertEquals(200, response.statusCode());
}

@Test
void responseBodyIsNotEmpty() throws Exception {
stubFor(get("/test").willReturn(ok("Hello from mock!")));

HttpResponse<String> response = sender.send("http://localhost:8089/test");
assertFalse(response.body().isEmpty());
}

@Test
void sendThrowsOnInvalidUrl() {
assertThrows(Exception.class, () -> sender.send("http://localhost:1"));
}
}