New posts
This commit is contained in:
@@ -0,0 +1,64 @@
|
||||
package lab.gatling;
|
||||
|
||||
import java.util.concurrent.ArrayBlockingQueue;
|
||||
import java.util.concurrent.Callable;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.RejectedExecutionException;
|
||||
import java.util.concurrent.ThreadPoolExecutor;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* A stand-in for a slow dependency with limited capacity (a legacy pricing service, a
|
||||
* connection-limited database). {@code workers} requests are served at once, each taking
|
||||
* about {@code delayMs}; up to {@code queueSize} more wait; everything beyond is rejected.
|
||||
*
|
||||
* Capacity is therefore workers / delay: 8 / 0.050 s = 160 requests per second.
|
||||
* Below that the service answers in ~50 ms. Above it the queue fills, latency climbs, and
|
||||
* once the queue is full requests are rejected. That is the knee the load test looks for.
|
||||
*/
|
||||
@Component
|
||||
public class DownstreamPool {
|
||||
|
||||
private final ThreadPoolExecutor executor;
|
||||
private final int delayMs;
|
||||
private final AtomicLong rejected = new AtomicLong();
|
||||
|
||||
public DownstreamPool(@Value("${lab.downstream.workers:8}") int workers,
|
||||
@Value("${lab.downstream.queue:100}") int queueSize,
|
||||
@Value("${lab.downstream.delay-ms:50}") int delayMs) {
|
||||
this.delayMs = delayMs;
|
||||
this.executor = new ThreadPoolExecutor(workers, workers, 0, TimeUnit.SECONDS,
|
||||
new ArrayBlockingQueue<>(queueSize), new ThreadPoolExecutor.AbortPolicy());
|
||||
}
|
||||
|
||||
/** Runs the lookup on the bounded pool and waits for it. Throws {@link Saturated} when full. */
|
||||
public double price(String symbol) throws InterruptedException {
|
||||
Callable<Double> lookup = () -> {
|
||||
// 0.9x - 1.1x of the nominal delay, derived from the symbol so runs are repeatable
|
||||
long ms = delayMs - delayMs / 10 + Math.floorMod(symbol.hashCode(), Math.max(1, delayMs / 5 + 1));
|
||||
Thread.sleep(ms);
|
||||
return 10 + Math.floorMod(symbol.hashCode(), 49000) / 100.0;
|
||||
};
|
||||
try {
|
||||
return executor.submit(lookup).get();
|
||||
} catch (RejectedExecutionException e) {
|
||||
rejected.incrementAndGet();
|
||||
throw new Saturated();
|
||||
} catch (ExecutionException e) {
|
||||
throw new IllegalStateException(e.getCause());
|
||||
}
|
||||
}
|
||||
|
||||
public Stats stats() {
|
||||
return new Stats(executor.getActiveCount(), executor.getQueue().size(),
|
||||
executor.getCompletedTaskCount(), rejected.get());
|
||||
}
|
||||
|
||||
public record Stats(int active, int queued, long completed, long rejected) { }
|
||||
|
||||
public static class Saturated extends RuntimeException { }
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package lab.gatling;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
|
||||
@SpringBootApplication
|
||||
public class LabApplication {
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(LabApplication.class, args);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package lab.gatling;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
public class QuoteController {
|
||||
|
||||
private final DownstreamPool pool;
|
||||
|
||||
public QuoteController(DownstreamPool pool) {
|
||||
this.pool = pool;
|
||||
}
|
||||
|
||||
public record Quote(String symbol, double price) { }
|
||||
|
||||
@GetMapping("/api/quote/{symbol}")
|
||||
public Quote quote(@PathVariable String symbol) throws InterruptedException {
|
||||
return new Quote(symbol, pool.price(symbol));
|
||||
}
|
||||
|
||||
/** Diagnostic endpoint: delete before shipping. */
|
||||
@GetMapping("/api/stats")
|
||||
public DownstreamPool.Stats stats() {
|
||||
return pool.stats();
|
||||
}
|
||||
|
||||
@ExceptionHandler(DownstreamPool.Saturated.class)
|
||||
ResponseEntity<String> saturated() {
|
||||
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).header("Retry-After", "1").body("downstream saturated");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
server.port=${PORT:18300}
|
||||
spring.application.name=gatling-spring-boot-lab
|
||||
@@ -0,0 +1,44 @@
|
||||
package lab.gatling;
|
||||
|
||||
import static io.gatling.javaapi.core.CoreDsl.*;
|
||||
import static io.gatling.javaapi.http.HttpDsl.*;
|
||||
|
||||
import io.gatling.javaapi.core.FeederBuilder;
|
||||
import io.gatling.javaapi.core.ScenarioBuilder;
|
||||
import io.gatling.javaapi.core.Simulation;
|
||||
import java.time.Duration;
|
||||
|
||||
/**
|
||||
* A scenario with a feeder, checks and a mix of injection steps.
|
||||
* Every user looks up two random symbols with a short think time in between.
|
||||
*/
|
||||
public class BasicSimulation extends Simulation {
|
||||
|
||||
FeederBuilder<String> symbols = csv("data/symbols.csv").random();
|
||||
|
||||
ScenarioBuilder browse = scenario("Browse quotes")
|
||||
.feed(symbols)
|
||||
.exec(http("get quote")
|
||||
.get("/api/quote/#{symbol}")
|
||||
.check(
|
||||
status().is(200),
|
||||
jmesPath("symbol").isEL("#{symbol}"),
|
||||
jmesPath("price").ofDouble().gt(0.0)))
|
||||
.pause(Duration.ofMillis(200), Duration.ofMillis(500))
|
||||
.feed(symbols)
|
||||
.exec(http("get another quote")
|
||||
.get("/api/quote/#{symbol}")
|
||||
.check(status().is(200)));
|
||||
|
||||
{
|
||||
setUp(browse.injectOpen(
|
||||
atOnceUsers(5),
|
||||
nothingFor(Duration.ofSeconds(2)),
|
||||
rampUsers(20).during(Duration.ofSeconds(5)),
|
||||
constantUsersPerSec(10).during(Duration.ofSeconds(10))))
|
||||
.protocols(Config.protocol())
|
||||
.assertions(
|
||||
global().responseTime().percentile(95).lt(200),
|
||||
global().successfulRequests().percent().gt(99.0));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package lab.gatling;
|
||||
|
||||
import static io.gatling.javaapi.core.CoreDsl.*;
|
||||
import static io.gatling.javaapi.http.HttpDsl.*;
|
||||
|
||||
import io.gatling.javaapi.core.ScenarioBuilder;
|
||||
import io.gatling.javaapi.core.Simulation;
|
||||
import java.time.Duration;
|
||||
|
||||
/**
|
||||
* CLOSED model: 50 virtual users, each sends its next request only after the previous one returned.
|
||||
* The server can never be asked for more than 50 requests at once, so it is never overloaded
|
||||
* the way an open arrival rate can overload it.
|
||||
*/
|
||||
public class ClosedModelSimulation extends Simulation {
|
||||
|
||||
ScenarioBuilder loop = scenario("Closed loop")
|
||||
.feed(csv("data/symbols.csv").random())
|
||||
.during(Duration.ofSeconds(15)).on(
|
||||
exec(http("get quote").get("/api/quote/#{symbol}").check(status().is(200))));
|
||||
|
||||
{
|
||||
setUp(loop.injectClosed(constantConcurrentUsers(50).during(Duration.ofSeconds(15))))
|
||||
.protocols(Config.protocol());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package lab.gatling;
|
||||
|
||||
import static io.gatling.javaapi.http.HttpDsl.http;
|
||||
|
||||
import io.gatling.javaapi.http.HttpProtocolBuilder;
|
||||
|
||||
/** Shared protocol so every simulation targets the same app. */
|
||||
final class Config {
|
||||
private Config() { }
|
||||
|
||||
static String baseUrl() {
|
||||
String url = System.getenv("APP_URL");
|
||||
return url != null ? url : "http://localhost:18300";
|
||||
}
|
||||
|
||||
static HttpProtocolBuilder protocol() {
|
||||
return http.baseUrl(baseUrl())
|
||||
.acceptHeader("application/json")
|
||||
.userAgentHeader("gatling-lab");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package lab.gatling;
|
||||
|
||||
import static io.gatling.javaapi.core.CoreDsl.*;
|
||||
import static io.gatling.javaapi.http.HttpDsl.*;
|
||||
|
||||
import io.gatling.javaapi.core.ScenarioBuilder;
|
||||
import io.gatling.javaapi.core.Simulation;
|
||||
import java.time.Duration;
|
||||
|
||||
/**
|
||||
* Deliberately fails: 220 users/s is above the ~160/s capacity, but the assertions below
|
||||
* demand a p95 under 200 ms and at least 99% success. Gatling exits non-zero, so Maven fails the build.
|
||||
*/
|
||||
public class FailingAssertionSimulation extends Simulation {
|
||||
|
||||
ScenarioBuilder oneRequest = scenario("One quote per user")
|
||||
.feed(csv("data/symbols.csv").random())
|
||||
.exec(http("get quote").get("/api/quote/#{symbol}").check(status().is(200)));
|
||||
|
||||
{
|
||||
setUp(oneRequest.injectOpen(constantUsersPerSec(220).during(Duration.ofSeconds(15))))
|
||||
.protocols(Config.protocol())
|
||||
.assertions(
|
||||
global().responseTime().percentile(95).lt(200),
|
||||
global().successfulRequests().percent().gt(99.0),
|
||||
details("get quote").responseTime().max().lt(1000));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package lab.gatling;
|
||||
|
||||
import static io.gatling.javaapi.core.CoreDsl.*;
|
||||
import static io.gatling.javaapi.http.HttpDsl.*;
|
||||
|
||||
import io.gatling.javaapi.core.ScenarioBuilder;
|
||||
import io.gatling.javaapi.core.Simulation;
|
||||
import java.time.Duration;
|
||||
|
||||
/** The stress profile: one continuous ramp from 10 to 250 users/s over 40 seconds. */
|
||||
public class KneeRampSimulation extends Simulation {
|
||||
|
||||
ScenarioBuilder oneRequest = scenario("One quote per user")
|
||||
.feed(csv("data/symbols.csv").random())
|
||||
.exec(http("get quote").get("/api/quote/#{symbol}").check(status().is(200)));
|
||||
|
||||
{
|
||||
setUp(oneRequest.injectOpen(
|
||||
rampUsersPerSec(10).to(250).during(Duration.ofSeconds(40))))
|
||||
.protocols(Config.protocol());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package lab.gatling;
|
||||
|
||||
import static io.gatling.javaapi.core.CoreDsl.*;
|
||||
import static io.gatling.javaapi.http.HttpDsl.*;
|
||||
|
||||
import io.gatling.javaapi.core.ScenarioBuilder;
|
||||
import io.gatling.javaapi.core.Simulation;
|
||||
import java.time.Duration;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
/**
|
||||
* Finds the knee. OPEN model: one request per arriving user, so the arrival rate IS the request rate.
|
||||
* Rates 30, 60, ... 240 users/s, each held 6 s, separated by 2 s ramps.
|
||||
*
|
||||
* Gatling's open-source report aggregates everything into one table, which would hide the knee.
|
||||
* So each request is NAMED after the step it was sent in ("rate 150/s"); the report then has one
|
||||
* row per step. Requests sent during a ramp between two steps are named "ramp".
|
||||
*/
|
||||
public class KneeStepsSimulation extends Simulation {
|
||||
|
||||
static final int STEPS = 8, RATE_STEP = 30, HOLD_S = 6, RAMP_S = 2;
|
||||
|
||||
// Set by the first user to arrive; every later user derives its step from the time elapsed since then.
|
||||
static final AtomicLong startedAt = new AtomicLong();
|
||||
|
||||
static String stepLabel() {
|
||||
long now = System.currentTimeMillis();
|
||||
startedAt.compareAndSet(0, now);
|
||||
long elapsedMs = now - startedAt.get();
|
||||
long slot = Math.max(0, elapsedMs) / ((HOLD_S + RAMP_S) * 1000L);
|
||||
long into = Math.max(0, elapsedMs) % ((HOLD_S + RAMP_S) * 1000L);
|
||||
if (into >= HOLD_S * 1000L || slot >= STEPS) {
|
||||
return "ramp";
|
||||
}
|
||||
return String.format("rate %03d/s", RATE_STEP * (slot + 1));
|
||||
}
|
||||
|
||||
ScenarioBuilder oneRequest = scenario("One quote per user")
|
||||
.feed(csv("data/symbols.csv").random())
|
||||
.exec(session -> session.set("step", stepLabel()))
|
||||
.exec(http("quote #{step}").get("/api/quote/#{symbol}").check(status().is(200)));
|
||||
|
||||
{
|
||||
setUp(oneRequest.injectOpen(
|
||||
incrementUsersPerSec(RATE_STEP)
|
||||
.times(STEPS)
|
||||
.eachLevelLasting(Duration.ofSeconds(HOLD_S))
|
||||
.separatedByRampsLasting(Duration.ofSeconds(RAMP_S))
|
||||
.startingFrom(RATE_STEP)))
|
||||
.protocols(Config.protocol());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package lab.gatling;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.util.concurrent.Callable;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* Proves the lab app has the capacity limit the post depends on, without any load tool:
|
||||
* 2 workers + a queue of 3 can hold 5 requests; the 6th and 7th are rejected.
|
||||
*/
|
||||
class SaturationTest {
|
||||
|
||||
@Test
|
||||
void poolRejectsOnceWorkersAndQueueAreFull() throws Exception {
|
||||
DownstreamPool pool = new DownstreamPool(2, 3, 400);
|
||||
List<Future<String>> results = new ArrayList<>();
|
||||
CountDownLatch go = new CountDownLatch(1);
|
||||
try (ExecutorService callers = Executors.newFixedThreadPool(7)) {
|
||||
for (int i = 0; i < 7; i++) {
|
||||
final int n = i;
|
||||
Callable<String> call = () -> {
|
||||
go.await();
|
||||
Thread.sleep(n * 15L); // stagger so the first five are admitted in order
|
||||
try {
|
||||
pool.price("ORCL");
|
||||
return "ok";
|
||||
} catch (DownstreamPool.Saturated e) {
|
||||
return "rejected";
|
||||
}
|
||||
};
|
||||
results.add(callers.submit(call));
|
||||
}
|
||||
go.countDown();
|
||||
long ok = 0;
|
||||
long rejected = 0;
|
||||
for (Future<String> f : results) {
|
||||
if (f.get().equals("ok")) ok++; else rejected++;
|
||||
}
|
||||
DownstreamPool.Stats stats = pool.stats();
|
||||
new Transcript("01-saturation-test.txt")
|
||||
.line("workers=2 queue=3 delay=400ms, 7 simultaneous callers")
|
||||
.line("served=%d rejected=%d", ok, rejected)
|
||||
.line("pool stats: completed=%d rejected=%d", stats.completed(), stats.rejected())
|
||||
.write();
|
||||
assertThat(ok).isEqualTo(5);
|
||||
assertThat(rejected).isEqualTo(2);
|
||||
assertThat(stats.rejected()).isEqualTo(2);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package lab.gatling;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/** Collects lines during a test and writes them to output/NN-name.txt. */
|
||||
final class Transcript {
|
||||
private final String file;
|
||||
private final List<String> lines = new ArrayList<>();
|
||||
|
||||
Transcript(String file) {
|
||||
this.file = file;
|
||||
}
|
||||
|
||||
Transcript line(String format, Object... args) {
|
||||
lines.add(args.length == 0 ? format : String.format(format, args));
|
||||
return this;
|
||||
}
|
||||
|
||||
void write() {
|
||||
try {
|
||||
Path dir = Path.of("output");
|
||||
Files.createDirectories(dir);
|
||||
Files.writeString(dir.resolve(file), String.join("\n", lines) + "\n");
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
symbol
|
||||
ORCL
|
||||
MSFT
|
||||
GOOG
|
||||
AMZN
|
||||
TCS
|
||||
INFY
|
||||
HDFC
|
||||
RELI
|
||||
SBIN
|
||||
WIPRO
|
||||
LT
|
||||
ITC
|
||||
|
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Gatling logs through logback, and Spring Boot's logback support is also on the test classpath.
|
||||
Without this file logback prints a screenful of its own status lines before every simulation. -->
|
||||
<configuration>
|
||||
<statusListener class="ch.qos.logback.core.status.NopStatusListener"/>
|
||||
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
|
||||
<encoder><pattern>%d{HH:mm:ss.SSS} [%-5level] %logger{15} - %msg%n</pattern></encoder>
|
||||
</appender>
|
||||
<root level="WARN"><appender-ref ref="CONSOLE"/></root>
|
||||
</configuration>
|
||||
Reference in New Issue
Block a user