Skip to content

Commit 0bc368d

Browse files
authored
add support for load shapes (#105)
- add support for load shapes - add load shapes for step load and spike patterns - update dependencies - update README.md - fix bug in beacon user
1 parent 97bc635 commit 0bc368d

File tree

11 files changed

+1204
-1033
lines changed

11 files changed

+1204
-1033
lines changed

README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,9 @@ This will run a load test with a general BSC profile.
112112

113113
### Parameters and Flags
114114
- `-p, --profile`: Specifies the profile to use for the benchmark. Available profiles can be found in the profile directory. Sample usage `-p bsc.general`
115+
- `-s, --shape`: Specifies the shape of the load pattern. List available shapes with `chainbench list shapes`.
115116
- `-u, --users`: Sets the number of simulated users to use for the benchmark.
117+
- `-r, --spawn-rate`: Sets the spawn rate of users per second.
116118
- `-w, --workers`: Sets the number of worker threads to use for the benchmark.
117119
- `-t, --test-time`: Sets the duration of the test to run.
118120
- `--target`: Specifies the target blockchain node URL that the benchmark will connect to.
@@ -155,6 +157,15 @@ Here's an example of how to run a load test for Ethereum using the `evm.light` p
155157
chainbench start --profile evm.light --users 50 --workers 2 --test-time 12h --target https://node-url --headless --autoquit
156158
```
157159

160+
## Load Pattern Shapes
161+
Load pattern shapes are used to define how the load will be distributed over time. You may specify the shape of the load pattern using the `-s` or `--shape` flag.
162+
This is an optional flag and if not specified, the default shape will be used. The default shape is `ramp-up` which means the load will increase linearly over time at
163+
the spawn-rate until the specified number of users is reached, after that it will maintain the number of users until test duration is over.
164+
165+
Other available shapes are:
166+
- `step` - The load will increase in steps. `--spawn-rate` flag is required to specify the step size. The number of steps will be calculated based on `--users` divided by `--spawn-rate`. The duration of each step will be calculated based on `--test-time` divided by the number of steps.
167+
- `spike` - The load will run in a spike pattern. The load will ramp up to 10% of the total users for 40% of the test duration and then spike to 100% of the total users as specified by `--users` for 20% of test duration and then reduce back to 10% of total users until the test duration is over.
168+
158169
### Test Data Size
159170
You may specify the test data size using the `--size` flag. This will determine how much data is used in the test.
160171
Take note that larger data size will result in longer test data generation time before the test starts.

chainbench/main.py

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import logging
21
import os
32
import shlex
43
import subprocess
@@ -34,8 +33,6 @@
3433
DEFAULT_PROFILE = "ethereum.general"
3534
NOTIFY_URL_TEMPLATE = "https://ntfy.sh/{topic}"
3635

37-
logger = logging.getLogger(__name__)
38-
3936

4037
@click.group(
4138
help="Tool for flexible blockchain infrastructure benchmarking.",
@@ -108,6 +105,12 @@ def validate_profile(ctx: Context, param: Parameter, value: str) -> str:
108105
help="Profile to run",
109106
show_default=True,
110107
)
108+
@click.option(
109+
"-s",
110+
"--shape",
111+
default=None,
112+
help="Shape of load pattern",
113+
)
111114
@click.option("-H", "--host", default=MASTER_HOST, help="Host to run on", show_default=True)
112115
@click.option("-P", "--port", default=MASTER_PORT, help="Port to run on", show_default=True)
113116
@click.option(
@@ -188,6 +191,7 @@ def start(
188191
ctx: Context,
189192
profile: str,
190193
profile_dir: Path | None,
194+
shape: str | None,
191195
host: str,
192196
port: int,
193197
workers: int,
@@ -248,12 +252,12 @@ def start(
248252
sys.exit(1)
249253

250254
if test_by_directory:
251-
from locust.argument_parser import find_locustfiles
255+
from locust.argument_parser import parse_locustfile_paths
252256
from locust.util.load_locustfile import load_locustfile
253257

254258
user_classes = {}
255259
test_data_types = set()
256-
for locustfile in find_locustfiles([profile_path.__str__()], True):
260+
for locustfile in parse_locustfile_paths([profile_path.__str__()]):
257261
_, _user_classes, _ = load_locustfile(locustfile)
258262
for key, value in _user_classes.items():
259263
user_classes[key] = value
@@ -286,6 +290,13 @@ def start(
286290
click.echo(f"Testing profile: {profile}")
287291
test_plan = profile
288292

293+
if shape is not None:
294+
shapes_dir = get_base_path(__file__) / "shapes"
295+
shape_path = get_profile_path(shapes_dir, shape)
296+
click.echo(f"Using load shape: {shape}")
297+
else:
298+
shape_path = None
299+
289300
results_dir = Path(results_dir).resolve()
290301
results_path = ensure_results_dir(profile=profile, parent_dir=results_dir, run_id=run_id)
291302

@@ -331,6 +342,7 @@ def start(
331342
target=target,
332343
custom_tags=custom_tags,
333344
exclude_tags=custom_exclude_tags,
345+
shape_path=shape_path,
334346
timescale=timescale,
335347
pg_host=pg_host,
336348
pg_port=pg_port,
@@ -480,6 +492,14 @@ def profiles(profile_dir: Path) -> None:
480492
click.echo(profile)
481493

482494

495+
@_list.command(
496+
help="Lists all available load shapes.",
497+
)
498+
def shapes() -> None:
499+
for shape in get_profiles(get_base_path(__file__) / "shapes"):
500+
click.echo(shape)
501+
502+
483503
@_list.command(
484504
help="Lists all available methods.",
485505
)

chainbench/shapes/spike.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
from locust import LoadTestShape
2+
3+
4+
class SpikeLoadShape(LoadTestShape):
5+
"""
6+
A step load shape class that has the following shape:
7+
10% of users start at the beginning for 40% of the test duration, then 100% of users for 20% of the test duration,
8+
then 10% of users until the end of the test duration.
9+
"""
10+
11+
use_common_options = True
12+
13+
def tick(self):
14+
run_time = self.get_run_time()
15+
total_run_time = self.runner.environment.parsed_options.run_time
16+
period_duration = round(total_run_time / 10)
17+
spike_run_time_start = period_duration * 4
18+
spike_run_time_end = period_duration * 6
19+
20+
if run_time < spike_run_time_start:
21+
user_count = round(self.runner.environment.parsed_options.num_users / 10)
22+
return user_count, self.runner.environment.parsed_options.spawn_rate
23+
elif run_time < spike_run_time_end:
24+
return self.runner.environment.parsed_options.num_users, self.runner.environment.parsed_options.spawn_rate
25+
elif run_time < total_run_time:
26+
user_count = round(self.runner.environment.parsed_options.num_users / 10)
27+
return user_count, self.runner.environment.parsed_options.spawn_rate
28+
return None

chainbench/shapes/step.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import math
2+
3+
from locust import LoadTestShape
4+
5+
6+
class StepLoadShape(LoadTestShape):
7+
"""
8+
This load shape determines the number of steps by using the total number of users divided by the spawn rate.
9+
Duration of each step is calculated by dividing the total run time by the number of steps equally.
10+
"""
11+
12+
use_common_options = True
13+
14+
def tick(self):
15+
run_time = self.get_run_time()
16+
total_run_time = self.runner.environment.parsed_options.run_time
17+
18+
if run_time < total_run_time:
19+
step = self.runner.environment.parsed_options.spawn_rate
20+
users = self.runner.environment.parsed_options.num_users
21+
no_of_steps = round(users / step)
22+
step_time = total_run_time / no_of_steps
23+
user_count = min(step * math.ceil(run_time / step_time), users)
24+
return user_count, step
25+
return None

chainbench/user/http.py

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -58,11 +58,6 @@ def check_fatal(self, response: ResponseContextManager) -> None:
5858
self.logger.critical(f"Redirect error: {response.url}")
5959

6060
def check_http_error(self, response: ResponseContextManager) -> None:
61-
if response.request is not None:
62-
self.logger.debug(f"Request: {response.request.method} {response.request.url_split}")
63-
if response.request.body is not None:
64-
self.logger.debug(f"{response.request.body}")
65-
6661
"""Check the response for errors."""
6762
if response.status_code != 200:
6863
self.logger.error(f"Request failed with {response.status_code} code")

chainbench/user/jsonrpc.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import logging
12
import random
23
import typing as t
34

@@ -94,7 +95,7 @@ def make_rpc_call(
9495
rpc_call: RpcCall | None = None,
9596
method: str | None = None,
9697
params: list[t.Any] | dict | None = None,
97-
name: str = "",
98+
name: str | None = None,
9899
path: str = "",
99100
) -> None:
100101
"""Make a JSON-RPC call."""
@@ -103,15 +104,18 @@ def make_rpc_call(
103104
raise ValueError("Either rpc_call or method must be provided")
104105
else:
105106
rpc_call = RpcCall(method, params)
106-
name = method
107-
else:
107+
if name is None:
108108
name = rpc_call.method
109109

110110
with self.client.request(
111111
"POST", self.rpc_path + path, json=rpc_call.request_body(), name=name, catch_response=True
112112
) as response:
113113
self.check_http_error(response)
114114
self.check_json_rpc_response(response, name=name)
115+
if logging.getLogger("locust").level == logging.DEBUG:
116+
self.logger.debug(f"jsonrpc: {rpc_call.method} - params: {rpc_call.params}, response: {response.text}")
117+
else:
118+
self.logger.info(f"jsonrpc: {rpc_call.method} - params: {rpc_call.params}")
115119

116120
def make_batch_rpc_call(self, rpc_calls: list[RpcCall], name: str = "", path: str = "") -> None:
117121
"""Make a Batch JSON-RPC call."""

chainbench/user/protocol/ethereum.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,10 @@ def task_to_method(task_name: str) -> str:
110110

111111
@classmethod
112112
def method_to_task_function(cls, method: str) -> t.Callable:
113-
return getattr(cls, f"{method}_task")
113+
if not method.endswith("task"):
114+
return getattr(cls, f"{method}_task")
115+
else:
116+
return getattr(cls, method)
114117

115118
def eth_v1_beacon_states_head_fork_task(self):
116119
self.get(

chainbench/util/cli.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,10 @@ def get_profile_path(base_path: Path, profile: str) -> Path:
3030

3131
def get_profiles(profile_dir: Path) -> list[str]:
3232
"""Get list of profiles in given directory."""
33-
from locust.argument_parser import find_locustfiles
33+
from locust.argument_parser import parse_locustfile_paths
3434

3535
result = []
36-
for locustfile in find_locustfiles([profile_dir.__str__()], True):
36+
for locustfile in parse_locustfile_paths([profile_dir.__str__()]):
3737
locustfile_path = Path(locustfile).relative_to(profile_dir)
3838
if locustfile_path.parent.__str__() != ".":
3939
result.append(".".join(locustfile_path.parts[:-1]) + "." + locustfile_path.parts[-1][:-3])
@@ -121,6 +121,7 @@ class LocustOptions:
121121
custom_tags: list[str]
122122
exclude_tags: list[str]
123123
target: str
124+
shape_path: Path | None = None
124125
headless: bool = False
125126
timescale: bool = False
126127
pg_host: str | None = None
@@ -136,8 +137,9 @@ class LocustOptions:
136137

137138
def get_master_command(self) -> str:
138139
"""Generate master command."""
140+
profile_args = f"{self.profile_path},{self.shape_path}" if self.shape_path else self.profile_path
139141
command = (
140-
f"locust -f {self.profile_path} --master "
142+
f"locust -f {profile_args} --master "
141143
f"--master-bind-host {self.host} --master-bind-port {self.port} "
142144
f"--web-host {self.host} "
143145
f"-u {self.users} -r {self.spawn_rate} --run-time {self.test_time} "
@@ -154,8 +156,9 @@ def get_master_command(self) -> str:
154156

155157
def get_worker_command(self, worker_id: int = 0) -> str:
156158
"""Generate worker command."""
159+
profile_args = f"{self.profile_path},{self.shape_path}" if self.shape_path else self.profile_path
157160
command = (
158-
f"locust -f {self.profile_path} --worker --master-host {self.host} --master-port {self.port} "
161+
f"locust -f {profile_args} --worker --master-host {self.host} --master-port {self.port} "
159162
f"--logfile {self.results_path}/worker_{worker_id}.log --loglevel {self.log_level} --stop-timeout 30"
160163
)
161164
return self.get_extra_options(command)

chainbench/util/http.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,6 @@ def json(self) -> dict[str, t.Any]:
4949
logger.error("Response is not json: %s", self.content)
5050
raise
5151
else:
52-
logger.debug(f"Response: {self.content}")
5352
return data
5453

5554
def check_http_error(self, request_uri: str = "", error_level: HttpErrorLevel = HttpErrorLevel.ClientError) -> None:

0 commit comments

Comments
 (0)