Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adding ability to generate any custom load shape with LoadTestShape class #1505

Merged
merged 19 commits into from
Aug 17, 2020
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
38 changes: 38 additions & 0 deletions docs/generating-custom-load-shape.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
.. _generating-custom-load-shape:

=================================
Generating a custom load shape using a `LoadTestShape` class
=================================

Sometimes a completely custom shaped load test is required that cannot be achieved by simply setting or changing the user count and hatch rate. For example, generating a spike during a test or ramping up and down at custom times. In these cases using the `LoadTestShape` class can give complete control over the user count and hatch rate at all times.

How does a `LoadTestShape` class work?
---------------------------------------------

Define your class inheriting the `LoadTestShape` class in your locust file. If this type of class is found then it will be automatically used by Locust. Add a `tick()` method to return either a tuple containing the user count and hatch rate or `None` to stop the load test. Locust will call the `tick()` method every second and update the load test with new settings or stop the test.

Examples
---------------------------------------------

There are also some [examples on github](https://github.com/locustio/locust/tree/master/examples/custom_shape) including:

- Generating a double wave shape
- Time based stages like K6
- Step load pattern like Visual Studio

Here is a simple example that will increase user count in blocks of 100 then stop the load test after 10 minutes:

```python
class MyCustomShape(LoadTestShape):
time_limit = 600
hatch_rate = 20

def tick(self):
run_time = self.get_run_time()

if run_time < self.time_limit:
user_count = round(run_time, -2)
return (user_count, hatch_rate)

return None
```
1 change: 1 addition & 0 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ Other functionalities
.. toctree ::
:maxdepth: 2

generating-custom-load-shape
running-locust-in-step-load-mode
retrieving-stats
testing-other-systems
Expand Down
6 changes: 6 additions & 0 deletions docs/running-locust-distributed.rst
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,12 @@ Running Locust distributed without the web UI
See :ref:`running-locust-distributed-without-web-ui`


Generating a custom load shape using a `LoadTestShape` class
=============================================

See :ref:`generating-custom-load-shape`


Running Locust distributed in Step Load mode
=============================================

Expand Down
43 changes: 43 additions & 0 deletions examples/custom_shape/double_wave.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import math
from locust import HttpUser, TaskSet, task, constant
from locust import LoadTestShape


class UserTasks(TaskSet):
@task
def get_root(self):
self.client.get("/")


class WebsiteUser(HttpUser):
wait_time = constant(0.5)
tasks = [UserTasks]


class DoubleWave(LoadTestShape):
"""
A shape to immitate some specific user behaviour. In this example, midday
and evening meal times.

Settings:
min_users -- minimum users
peak_one_users -- users in first peak
peak_two_users -- users in second peak
time_limit -- total length of test
"""

min_users = 20
peak_one_users = 60
peak_two_users = 40
time_limit = 600

def tick(self):
run_time = round(self.get_run_time())

if run_time < self.time_limit:
user_count = ((self.peak_one_users - self.min_users) * math.e ** -((run_time/(self.time_limit/10*2/3))-5) ** 2
+ (self.peak_two_users - self.min_users) * math.e ** - ((run_time/(self.time_limit/10*2/3))-10) ** 2
+ self.min_users)
return (round(user_count), round(user_count))
else:
return None
49 changes: 49 additions & 0 deletions examples/custom_shape/stages.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
from locust import HttpUser, TaskSet, task, constant
from locust import LoadTestShape


class UserTasks(TaskSet):
@task
def get_root(self):
self.client.get("/")


class WebsiteUser(HttpUser):
wait_time = constant(0.5)
tasks = [UserTasks]


class StagesShape(LoadTestShape):
"""
A simply load test shape class that has different user and hatch_rate at
different stages.

Keyword arguments:

stages -- A list of dicts, each representing a stage with the following keys:
duration -- When this many seconds pass the test is advanced to the next stage
users -- Total user count
hatch_rate -- Hatch rate
stop -- A boolean that can stop that test at a specific stage
Copy link
Collaborator

@cyberw cyberw Aug 7, 2020

Choose a reason for hiding this comment

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

Maybe a None entry could be better used to signal the end of the test? Because you'll never need to do (x, y, True) for any other values of x or y than zero, right?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Well we need to return at least (Int, Int) to edit the running test so you are saying to stop the test return (None, None)? Or (Int, Int, None)? Or something else?

Copy link
Collaborator

@cyberw cyberw Aug 10, 2020

Choose a reason for hiding this comment

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

I meant that tick() should do return None instead of return (0, 0, True) (which of course requires modifications in runners.py on line 297 as wel)

But, I also think that stop_at_end is an unnecessary complication. If you want to run your test forever, cant you just set the last step to use 'duration': 9999999 ?

Copy link
Contributor Author

@max-rocket-internet max-rocket-internet Aug 10, 2020

Choose a reason for hiding this comment

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

But, I also think that stop_at_end is an unnecessary complication

This is just an example of what I imagine someone might want to use or see before they write their own. But I can remove stop_at_end it to make it simpler, no worries.

cant you just set the last step to use 'duration': 9999999

I would say no, that's not very precise. What if you forget it's running and it gets to 10000000 seconds😃

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Just to reiterate, these are examples. I imagine they would be in the docs or just here for people to find. They aren't to be copy/pasted and used as they are. I just imagined them to help a beginner to see how this class could be used 🙂

Copy link
Collaborator

Choose a reason for hiding this comment

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

Sure. Lets not spend too much time on them. On the other hand, if they are just examples then it makes sense to keep them very simplified (and let our users worry about edge cases like how to make it run forever etc).


stop_at_end -- Can be set to stop once all stages have run.
"""

stages = [
{'duration': 60, 'users': 10, 'hatch_rate': 10},
{'duration': 100, 'users': 50, 'hatch_rate': 10},
{'duration': 180, 'users': 100, 'hatch_rate': 10},
{'duration': 220, 'users': 30, 'hatch_rate': 10},
{'duration': 230, 'users': 10, 'hatch_rate': 10},
{'duration': 240, 'users': 1, 'hatch_rate': 1},
]

def tick(self):
run_time = self.get_run_time()

for stage in self.stages:
if run_time < stage["duration"]:
tick_data = (stage["users"], stage["hatch_rate"])
return tick_data

return None
43 changes: 43 additions & 0 deletions examples/custom_shape/step_load.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import math
from locust import HttpUser, TaskSet, task, constant
from locust import LoadTestShape


class UserTasks(TaskSet):
@task
def get_root(self):
self.client.get("/")


class WebsiteUser(HttpUser):
wait_time = constant(0.5)
tasks = [UserTasks]


class StepLoadShape(LoadTestShape):
"""
A step load shape


Keyword arguments:

step_time -- Time between steps
step_load -- User increase amount at each step
hatch_rate -- Hatch rate to use at every step
time_limit -- Time limit in seconds

"""

step_time = 30
step_load = 10
hatch_rate = 10
time_limit = 600

def tick(self):
run_time = self.get_run_time()

if run_time > self.time_limit:
return None

current_step = math.floor(run_time / self.step_time) + 1
return (current_step * self.step_load, self.hatch_rate)
1 change: 1 addition & 0 deletions locust/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from .user.task import task, tag, TaskSet
from .user.users import HttpUser, User
from .user.wait_time import between, constant, constant_pacing
from .shape import LoadTestShape

from .event import Events
events = Events()
Expand Down
16 changes: 11 additions & 5 deletions locust/env.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from .runners import LocalRunner, MasterRunner, WorkerRunner
from .web import WebUI
from .user.task import filter_tasks_by_tags
from .shape import LoadTestShape


class Environment:
Expand All @@ -15,7 +16,10 @@ class Environment:

user_classes = []
"""User classes that the runner will run"""


shape_class = None
"""A shape class to control the shape of the load test"""

tags = None
"""If set, only tasks that are tagged by tags in this list will be executed"""

Expand Down Expand Up @@ -63,12 +67,13 @@ class Environment:
def __init__(
self, *,
user_classes=[],
shape_class=None,
tags=None,
exclude_tags=None,
events=None,
host=None,
reset_stats=False,
step_load=False,
events=None,
host=None,
reset_stats=False,
step_load=False,
stop_timeout=None,
catch_exceptions=True,
parsed_options=None,
Expand All @@ -79,6 +84,7 @@ def __init__(
self.events = Events()

self.user_classes = user_classes
self.shape_class = shape_class
self.tags = tags
self.exclude_tags = exclude_tags
self.stats = RequestStats()
Expand Down
37 changes: 31 additions & 6 deletions locust/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
from .user.inspectuser import get_task_ratio_dict, print_task_ratio
from .util.timespan import parse_timespan
from .exception import AuthCredentialsError
from .shape import LoadTestShape


version = locust.__version__

Expand All @@ -36,6 +38,16 @@ def is_user_class(item):
)


def is_shape_class(item):
"""
Check if a class is a LoadTestShape
"""
return bool(
inspect.isclass(item)
and issubclass(item, LoadTestShape)
and item.__dict__['__module__'] != 'locust.shape'
)

def load_locustfile(path):
"""
Import given locustfile path and return (docstring, callables).
Expand Down Expand Up @@ -84,15 +96,24 @@ def __import_locustfile__(filename, path):
del sys.path[0]
# Return our two-tuple
user_classes = {name:value for name, value in vars(imported).items() if is_user_class(value)}
return imported.__doc__, user_classes

# Find shape class, if any, return it
shape_classes = [value for name, value in vars(imported).items() if is_shape_class(value)]
if shape_classes:
shape_class = shape_classes[0]()
else:
shape_class = None

return imported.__doc__, user_classes, shape_class


def create_environment(user_classes, options, events=None):
def create_environment(user_classes, options, events=None, shape_class=None):
"""
Create an Environment instance from options
"""
return Environment(
user_classes=user_classes,
shape_class=shape_class,
tags=options.tags,
exclude_tags=options.exclude_tags,
events=events,
Expand All @@ -110,8 +131,8 @@ def main():
locustfile = parse_locustfile_option()

# import the locustfile
docstring, user_classes = load_locustfile(locustfile)
docstring, user_classes, shape_class = load_locustfile(locustfile)

# parse all command line options
options = parse_options()

Expand Down Expand Up @@ -163,8 +184,12 @@ def main():
logger.warning("System open file limit setting is not high enough for load testing, and the OS didn't allow locust to increase it by itself. See https://docs.locust.io/en/stable/installation.html#increasing-maximum-number-of-open-files-limit for more info.")

# create locust Environment
environment = create_environment(user_classes, options, events=locust.events)

environment = create_environment(user_classes, options, events=locust.events, shape_class=shape_class)

if shape_class and (options.num_users or options.hatch_rate or options.step_load):
logger.error("The specified locustfile contains a shape class but a conflicting argument was specified: users, hatch-rate or step-load")
sys.exit(1)

if options.show_task_ratio:
print("\n Task ratio per User class")
print( "-" * 80)
Expand Down
31 changes: 29 additions & 2 deletions locust/runners.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ def __init__(self, environment):
self.state = STATE_INIT
self.hatching_greenlet = None
self.stepload_greenlet = None
self.shape_greenlet = None
self.shape_last_state = None
self.current_cpu_usage = 0
self.cpu_warning_emitted = False
self.greenlet.spawn(self.monitor_cpu).link_exception(greenlet_exception_handler)
Expand Down Expand Up @@ -288,12 +290,37 @@ def stepload_worker(self, hatch_rate, step_users_growth, step_duration):
while self.state == STATE_INIT or self.state == STATE_HATCHING or self.state == STATE_RUNNING:
current_num_users += step_users_growth
if current_num_users > int(self.total_users):
logger.info('Step Load is finished.')
logger.info("Step Load is finished")
break
self.start(current_num_users, hatch_rate)
logger.info('Step loading: start hatch job of %d user.' % (current_num_users))
logger.info("Step loading: start hatch job of %d user" % (current_num_users))
gevent.sleep(step_duration)

def start_shape(self):
if self.shape_greenlet:
logger.info("There is an ongoing shape test running. Editing is disabled")
return

logger.info("Shape test starting. User count and hatch rate are ignored for this type of load test")
self.state = STATE_INIT
self.shape_greenlet = self.greenlet.spawn(self.shape_worker)
self.shape_greenlet.link_exception(greenlet_exception_handler)

def shape_worker(self):
logger.info("Shape worker starting")
while self.state == STATE_INIT or self.state == STATE_HATCHING or self.state == STATE_RUNNING:
new_state = self.environment.shape_class.tick()
if new_state is None:
logger.info("Shape test stopping")
self.stop()
elif self.shape_last_state == new_state:
gevent.sleep(1)
else:
user_count, hatch_rate = new_state
logger.info("Shape test updating to %d users at %.2f hatch rate" % (user_count, hatch_rate))
self.start(user_count=user_count, hatch_rate=hatch_rate)
self.shape_last_state = new_state

def stop(self):
"""
Stop a running load test by stopping all running users
Expand Down
Loading