io.hyperfoil.api.config.PhaseBuilder Maven / Gradle / Ivy
package io.hyperfoil.api.config;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Function;
import java.util.function.Predicate;
import java.util.stream.Collector;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
import io.hyperfoil.function.SerializableSupplier;
/**
* The builder creates a matrix of phases (not just single phase); we allow multiple iterations of a phase
* (with increasing number of users) and multiple forks (different scenarios, but same configuration).
*/
public abstract class PhaseBuilder> {
protected final String name;
protected final BenchmarkBuilder parent;
protected long startTime = -1;
protected Collection startAfter = new ArrayList<>();
protected Collection startAfterStrict = new ArrayList<>();
protected Collection terminateAfterStrict = new ArrayList<>();
protected long duration = -1;
protected long maxDuration = -1;
protected int maxIterations = 1;
protected boolean forceIterations = false;
protected List forks = new ArrayList<>();
protected boolean isWarmup = false;
protected Map>> customSlas = new HashMap<>();
protected PhaseBuilder(BenchmarkBuilder parent, String name) {
this.name = name;
this.parent = parent;
parent.addPhase(name, this);
}
public static Phase noop(SerializableSupplier benchmark, int id, int iteration, String iterationName, long duration,
Collection startAfter, Collection startAfterStrict, Collection terminateAfterStrict) {
Scenario scenario = new Scenario(new Sequence[0], new Sequence[0], 0, 0);
return new Phase(benchmark, id, iteration, iterationName, scenario, -1, startAfter, startAfterStrict, terminateAfterStrict, duration, duration, null, true, new Model.Noop(), Collections.emptyMap());
}
public BenchmarkBuilder endPhase() {
return parent;
}
public String name() {
return name;
}
public ScenarioBuilder scenario() {
if (forks.isEmpty()) {
PhaseForkBuilder fork = new PhaseForkBuilder(this, null);
forks.add(fork);
return fork.scenario;
} else if (forks.size() == 1 && forks.get(0).name == null) {
throw new BenchmarkDefinitionException("Scenario for " + name + " already set!");
} else {
throw new BenchmarkDefinitionException("Scenario is forked; you need to specify another fork.");
}
}
@SuppressWarnings("unchecked")
protected PB self() {
return (PB) this;
}
public PhaseForkBuilder fork(String name) {
if (forks.size() == 1 && forks.get(0).name == null) {
throw new BenchmarkDefinitionException("Scenario for " + name + " already set!");
} else {
PhaseForkBuilder fork = new PhaseForkBuilder(this, name);
forks.add(fork);
return fork;
}
}
public PB startTime(long startTime) {
this.startTime = startTime;
return self();
}
public PB startAfter(String phase) {
this.startAfter.add(new PhaseReference(phase, RelativeIteration.NONE, null));
return self();
}
public PB startAfter(PhaseReference phase) {
this.startAfter.add(phase);
return self();
}
public PB startAfterStrict(String phase) {
this.startAfterStrict.add(new PhaseReference(phase, RelativeIteration.NONE, null));
return self();
}
public PB startAfterStrict(PhaseReference phase) {
this.startAfterStrict.add(phase);
return self();
}
public PB duration(long duration) {
this.duration = duration;
return self();
}
public PB maxDuration(long maxDuration) {
this.maxDuration = maxDuration;
return self();
}
public PB maxIterations(int iterations) {
this.maxIterations = iterations;
return self();
}
public void prepareBuild() {
forks.forEach(fork -> fork.scenario.prepareBuild());
}
public Collection build(SerializableSupplier benchmark, AtomicInteger idCounter) {
// normalize fork weights first
if (forks.isEmpty()) {
throw new BenchmarkDefinitionException("Scenario for " + name + " is not defined.");
} else if (forks.size() == 1 && forks.get(0).name != null) {
throw new BenchmarkDefinitionException(name + " has single fork: define scenario directly.");
}
boolean hasForks = forks.size() > 1;
forks.removeIf(fork -> fork.weight <= 0);
if (forks.isEmpty()) {
throw new BenchmarkDefinitionException("Phase " + name + " does not have any forks with positive weight.");
}
double sumWeight = forks.stream().mapToDouble(f -> f.weight).sum();
forks.forEach(f -> f.weight /= sumWeight);
// create matrix of iteration|fork phases
List phases = IntStream.range(0, maxIterations)
.mapToObj(iteration -> forks.stream().map(f -> buildPhase(benchmark, idCounter.getAndIncrement(), iteration, f)))
.flatMap(Function.identity()).collect(Collectors.toList());
if (maxIterations > 1 || forceIterations) {
if (hasForks) {
// add phase covering forks in each iteration
IntStream.range(0, maxIterations).mapToObj(iteration -> {
String iterationName = formatIteration(name, iteration);
List forks = this.forks.stream().map(f -> iterationName + "/" + f.name).collect(Collectors.toList());
return noop(benchmark, idCounter.getAndIncrement(), iteration, iterationName, 0, forks, Collections.emptyList(), forks);
}).forEach(phases::add);
}
// Referencing phase with iterations with RelativeIteration.NONE means that it starts after all its iterations
List lastIteration = Collections.singletonList(formatIteration(name, maxIterations - 1));
phases.add(noop(benchmark, idCounter.getAndIncrement(), 0, name, 0, lastIteration, Collections.emptyList(), lastIteration));
} else if (hasForks) {
// add phase covering forks
List forks = this.forks.stream().map(f -> name + "/" + f.name).collect(Collectors.toList());
phases.add(noop(benchmark, idCounter.getAndIncrement(), 0, name, 0, forks, Collections.emptyList(), forks));
}
return phases;
}
protected Phase buildPhase(SerializableSupplier benchmark, int phaseId, int iteration, PhaseForkBuilder f) {
Collector>>, ?, Map> customSlaCollector =
Collectors.toMap(Map.Entry::getKey, entry -> entry.getValue().stream().map(SLABuilder::build).toArray(SLA[]::new));
return new Phase(benchmark, phaseId, iteration, iterationName(iteration, f.name), f.scenario.build(),
iterationStartTime(iteration),
iterationReferences(startAfter, iteration, false),
iterationReferences(startAfterStrict, iteration, true),
iterationReferences(terminateAfterStrict, iteration, false), duration,
maxDuration, sharedResources(f), isWarmup, createModel(iteration, f.weight),
Collections.unmodifiableMap(customSlas.entrySet().stream().collect(customSlaCollector)));
}
String iterationName(int iteration, String forkName) {
if (maxIterations == 1 && !forceIterations) {
assert iteration == 0;
if (forkName == null) {
return name;
} else {
return name + "/" + forkName;
}
} else {
String iterationName = formatIteration(name, iteration);
if (forkName == null) {
return iterationName;
} else {
return iterationName + "/" + forkName;
}
}
}
String formatIteration(String name, int iteration) {
return String.format("%s/%03d", name, iteration);
}
long iterationStartTime(int iteration) {
return iteration == 0 ? startTime : -1;
}
// Identifier for phase + fork, omitting iteration
String sharedResources(PhaseForkBuilder fork) {
if (fork == null || fork.name == null) {
return name;
} else {
return name + "/" + fork.name;
}
}
Collection iterationReferences(Collection refs, int iteration, boolean addSelfPrevious) {
Collection names = new ArrayList<>();
for (PhaseReference ref : refs) {
if (ref.iteration != RelativeIteration.NONE && maxIterations <= 1 && !forceIterations) {
String msg = "Phase " + name + " tries to reference " + ref.phase + "/" + ref.iteration +
(ref.fork == null ? "" : "/" + ref.fork) +
" but this phase does not have any iterations (cannot determine relative iteration).";
throw new BenchmarkDefinitionException(msg);
}
switch (ref.iteration) {
case NONE:
names.add(ref.phase);
break;
case PREVIOUS:
if (iteration > 0) {
names.add(formatIteration(ref.phase, iteration - 1));
}
break;
case SAME:
names.add(formatIteration(ref.phase, iteration));
break;
default:
throw new IllegalArgumentException();
}
}
if (addSelfPrevious && iteration > 0) {
names.add(formatIteration(name, iteration - 1));
}
return names;
}
public void readForksFrom(PhaseBuilder> other) {
for (PhaseForkBuilder builder : other.forks) {
fork(builder.name).readFrom(builder);
}
}
public void readCustomSlaFrom(PhaseBuilder> other) {
for (var entry : other.customSlas.entrySet()) {
customSlas.put(entry.getKey(), entry.getValue().stream().map(b -> {
@SuppressWarnings("unchecked")
SLABuilder copy = (SLABuilder) b.copy(PhaseBuilder.this);
return copy;
}).collect(Collectors.toList()));
}
}
public PB forceIterations(boolean force) {
this.forceIterations = force;
return self();
}
public PB isWarmup(boolean isWarmup) {
this.isWarmup = isWarmup;
return self();
}
public SLABuilder customSla(String metric) {
List> list = this.customSlas.computeIfAbsent(metric, m -> new ArrayList<>());
SLABuilder builder = new SLABuilder<>(self());
list.add(builder);
return builder;
}
protected abstract Model createModel(int iteration, double weight);
public static class Noop extends PhaseBuilder {
protected Noop(BenchmarkBuilder parent, String name) {
super(parent, name);
}
@Override
public Collection build(SerializableSupplier benchmark, AtomicInteger idCounter) {
List phases = IntStream.range(0, maxIterations)
.mapToObj(iteration -> PhaseBuilder.noop(benchmark, idCounter.getAndIncrement(),
iteration, iterationName(iteration, null), duration,
iterationReferences(startAfter, iteration, false),
iterationReferences(startAfterStrict, iteration, true),
iterationReferences(terminateAfterStrict, iteration, false)))
.collect(Collectors.toList());
if (maxIterations > 1 || forceIterations) {
// Referencing phase with iterations with RelativeIteration.NONE means that it starts after all its iterations
List lastIteration = Collections.singletonList(formatIteration(name, maxIterations - 1));
phases.add(noop(benchmark, idCounter.getAndIncrement(), 0, name, duration, lastIteration, Collections.emptyList(), lastIteration));
}
return phases;
}
@Override
protected Model createModel(int iteration, double weight) {
throw new UnsupportedOperationException();
}
}
public abstract static class ClosedModel> extends PhaseBuilder {
protected int users;
protected int usersIncrement;
protected int usersPerAgent;
protected int usersPerThread;
protected ClosedModel(BenchmarkBuilder parent, String name, int users) {
super(parent, name);
this.users = users;
}
public T users(int users) {
this.users = users;
return self();
}
public T users(int base, int increment) {
this.users = base;
this.usersIncrement = increment;
return self();
}
public T usersPerAgent(int usersPerAgent) {
this.usersPerAgent = usersPerAgent;
return self();
}
public T usersPerThread(int usersPerThread) {
this.usersPerThread = usersPerThread;
return self();
}
protected void validate() {
long propsSet = IntStream.of(users, usersPerAgent, usersPerThread).filter(u -> u > 0).count();
if (propsSet < 1) {
throw new BenchmarkDefinitionException("Phase " + name + ".users (or .usersPerAgent/.usersPerThread) must be positive.");
} else if (propsSet > 1) {
throw new BenchmarkDefinitionException("Phase " + name + ": you can set only one of .users, .usersPerAgent and .usersPerThread");
}
}
}
public static class AtOnce extends ClosedModel {
AtOnce(BenchmarkBuilder parent, String name, int users) {
super(parent, name, users);
}
@Override
protected Model createModel(int iteration, double weight) {
validate();
return new Model.AtOnce((int) Math.round((users + usersIncrement * iteration) * weight),
(int) Math.round(usersPerAgent * weight), (int) Math.round(usersPerThread * weight));
}
}
public static class Always extends ClosedModel {
Always(BenchmarkBuilder parent, String name, int users) {
super(parent, name, users);
}
@Override
protected Model createModel(int iteration, double weight) {
validate();
return new Model.Always((int) Math.round((this.users + usersIncrement * iteration) * weight),
(int) Math.round(this.usersPerAgent * weight), (int) Math.round(this.usersPerThread * weight));
}
}
public abstract static class OpenModel> extends PhaseBuilder
{
protected int maxSessions;
protected boolean variance = true;
protected SessionLimitPolicy sessionLimitPolicy = SessionLimitPolicy.FAIL;
protected OpenModel(BenchmarkBuilder parent, String name) {
super(parent, name);
}
@SuppressWarnings("unchecked")
public P maxSessions(int maxSessions) {
this.maxSessions = maxSessions;
return (P) this;
}
@SuppressWarnings("unchecked")
public P variance(boolean variance) {
this.variance = variance;
return (P) this;
}
@SuppressWarnings("unchecked")
public P sessionLimitPolicy(SessionLimitPolicy sessionLimitPolicy) {
this.sessionLimitPolicy = sessionLimitPolicy;
return (P) this;
}
}
public static class RampRate extends OpenModel {
private double initialUsersPerSec;
private double initialUsersPerSecIncrement;
private double targetUsersPerSec;
private double targetUsersPerSecIncrement;
private Predicate constraint;
private String constraintMessage;
RampRate(BenchmarkBuilder parent, String name, double initialUsersPerSec, double targetUsersPerSec) {
super(parent, name);
this.initialUsersPerSec = initialUsersPerSec;
this.targetUsersPerSec = targetUsersPerSec;
}
@Override
protected Model createModel(int iteration, double weight) {
int maxSessions;
if (this.maxSessions > 0) {
maxSessions = (int) Math.round(this.maxSessions * weight);
} else {
double maxInitialUsers = initialUsersPerSec + initialUsersPerSecIncrement * (maxIterations - 1);
double maxTargetUsers = targetUsersPerSec + targetUsersPerSecIncrement * (maxIterations - 1);
maxSessions = (int) Math.ceil(Math.max(maxInitialUsers, maxTargetUsers) * weight);
}
if (initialUsersPerSec < 0) {
throw new BenchmarkDefinitionException("Phase " + name + ".initialUsersPerSec must be non-negative");
}
if (targetUsersPerSec < 0) {
throw new BenchmarkDefinitionException("Phase " + name + ".targetUsersPerSec must be non-negative");
}
if (initialUsersPerSec < 0.0001 && targetUsersPerSec < 0.0001) {
throw new BenchmarkDefinitionException("In phase " + name + " both initialUsersPerSec and targetUsersPerSec are 0");
}
double initial = (this.initialUsersPerSec + initialUsersPerSecIncrement * iteration) * weight;
double target = (this.targetUsersPerSec + targetUsersPerSecIncrement * iteration) * weight;
Model.RampRate model = new Model.RampRate(initial, target, variance, maxSessions, sessionLimitPolicy);
if (constraint != null && !constraint.test(model)) {
throw new BenchmarkDefinitionException("Phase " + name + " failed constraints: " + constraintMessage);
}
return model;
}
public RampRate initialUsersPerSec(double initialUsersPerSec) {
this.initialUsersPerSec = initialUsersPerSec;
this.initialUsersPerSecIncrement = 0;
return this;
}
public RampRate initialUsersPerSec(double base, double increment) {
this.initialUsersPerSec = base;
this.initialUsersPerSecIncrement = increment;
return this;
}
public RampRate targetUsersPerSec(double targetUsersPerSec) {
this.targetUsersPerSec = targetUsersPerSec;
this.targetUsersPerSecIncrement = 0;
return this;
}
public RampRate targetUsersPerSec(double base, double increment) {
this.targetUsersPerSec = base;
this.targetUsersPerSecIncrement = increment;
return this;
}
public RampRate constraint(Predicate constraint, String constraintMessage) {
this.constraint = constraint;
this.constraintMessage = constraintMessage;
return this;
}
}
public static class ConstantRate extends OpenModel {
private double usersPerSec;
private double usersPerSecIncrement;
ConstantRate(BenchmarkBuilder parent, String name, double usersPerSec) {
super(parent, name);
this.usersPerSec = usersPerSec;
}
@Override
protected Model createModel(int iteration, double weight) {
int maxSessions;
if (this.maxSessions <= 0) {
maxSessions = (int) Math.ceil(weight * (usersPerSec + usersPerSecIncrement * (maxIterations - 1)));
} else {
maxSessions = (int) Math.round(this.maxSessions * weight);
}
if (usersPerSec <= 0) {
throw new BenchmarkDefinitionException("Phase " + name + ".usersPerSec must be positive.");
}
double rate = (this.usersPerSec + usersPerSecIncrement * iteration) * weight;
return new Model.ConstantRate(rate, variance, maxSessions, sessionLimitPolicy);
}
public ConstantRate usersPerSec(double usersPerSec) {
this.usersPerSec = usersPerSec;
return this;
}
public ConstantRate usersPerSec(double base, double increment) {
this.usersPerSec = base;
this.usersPerSecIncrement = increment;
return this;
}
}
public static class Sequentially extends PhaseBuilder {
private int repeats;
protected Sequentially(BenchmarkBuilder parent, String name, int repeats) {
super(parent, name);
this.repeats = repeats;
}
@Override
protected Model createModel(int iteration, double weight) {
if (repeats <= 0) {
throw new BenchmarkDefinitionException("Phase " + name + ".repeats must be positive");
}
return new Model.Sequentially(repeats);
}
}
public static class Catalog {
private final BenchmarkBuilder parent;
private final String name;
Catalog(BenchmarkBuilder parent, String name) {
this.parent = parent;
this.name = name;
}
public Noop noop() {
return new PhaseBuilder.Noop(parent, name);
}
public AtOnce atOnce(int users) {
return new PhaseBuilder.AtOnce(parent, name, users);
}
public Always always(int users) {
return new PhaseBuilder.Always(parent, name, users);
}
public RampRate rampRate(int initialUsersPerSec, int targetUsersPerSec) {
return new RampRate(parent, name, initialUsersPerSec, targetUsersPerSec);
}
public ConstantRate constantRate(int usersPerSec) {
return new ConstantRate(parent, name, usersPerSec);
}
public Sequentially sequentially(int repeats) {
return new Sequentially(parent, name, repeats);
}
}
}