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

Support Step Load Pattern #1002

Merged
merged 10 commits into from
Dec 9, 2019
92 changes: 63 additions & 29 deletions locust/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ def parse_options(args=None, default_config_files=['~/.locust.conf','locust.conf
'-t', '--run-time',
help="Stop after the specified amount of time, e.g. (300s, 20m, 3h, 1h30m, etc.). Only used together with --no-web"
)

# skip logging setup
parser.add_argument(
'--skip-log-setup',
Expand All @@ -175,6 +175,27 @@ def parse_options(args=None, default_config_files=['~/.locust.conf','locust.conf
help="Disable Locust's logging setup. Instead, the configuration is provided by the Locust test or Python defaults."
)

# Enable Step Load mode
parser.add_argument(
'--step-load',
action='store_true',
help="Enable Step Load mode to monitor how performance metrics varies when user load increases. Requires --step-clients and --step-time to be specified."
)

# Number of clients to incease by Step
parser.add_argument(
'--step-clients',
type=int,
default=1,
help="Client count to increase by step in Step Load mode. Only used together with --step-load"
)

# Time limit of each step
parser.add_argument(
'--step-time',
help="Step duration in Step Load mode, e.g. (300s, 20m, 3h, 1h30m, etc.). Only used together with --step-load"
)

# log level
parser.add_argument(
'--loglevel', '-L',
Expand Down Expand Up @@ -450,6 +471,9 @@ def main():
if not options.no_web:
logger.error("The --run-time argument can only be used together with --no-web")
sys.exit(1)
if options.slave:
logger.error("--run-time should be specified on the master node, and not on slave nodes")
sys.exit(1)
try:
options.run_time = parse_timespan(options.run_time)
except ValueError:
Expand All @@ -462,42 +486,52 @@ def timelimit_stop():
runners.locust_runner.quit()
gevent.spawn_later(options.run_time, timelimit_stop)

if not options.no_web and not options.slave:
# spawn web greenlet
logger.info("Starting web monitor at http://%s:%s" % (options.web_host or "*", options.port))
main_greenlet = gevent.spawn(web.start, locust_classes, options)
if options.step_time:
if not options.step_load:
logger.error("The --step-time argument can only be used together with --step-load")
sys.exit(1)
if options.slave:
logger.error("--step-time should be specified on the master node, and not on slave nodes")
sys.exit(1)
try:
options.step_time = parse_timespan(options.step_time)
except ValueError:
logger.error("Valid --step-time formats are: 20, 20s, 3m, 2h, 1h20m, 3h30m10s, etc.")
sys.exit(1)

if not options.master and not options.slave:
runners.locust_runner = LocalLocustRunner(locust_classes, options)
# spawn client spawning/hatching greenlet
if options.no_web:
runners.locust_runner.start_hatching(wait=True)
main_greenlet = runners.locust_runner.greenlet
if options.run_time:
spawn_run_time_limit_greenlet()
elif options.master:
if options.master:
runners.locust_runner = MasterLocustRunner(locust_classes, options)
if options.no_web:
while len(runners.locust_runner.clients.ready)<options.expect_slaves:
logging.info("Waiting for slaves to be ready, %s of %s connected",
len(runners.locust_runner.clients.ready), options.expect_slaves)
time.sleep(1)

runners.locust_runner.start_hatching(options.num_clients, options.hatch_rate)
main_greenlet = runners.locust_runner.greenlet
if options.run_time:
spawn_run_time_limit_greenlet()
elif options.slave:
if options.run_time:
logger.error("--run-time should be specified on the master node, and not on slave nodes")
sys.exit(1)
try:
runners.locust_runner = SlaveLocustRunner(locust_classes, options)
main_greenlet = runners.locust_runner.greenlet
except socket.error as e:
logger.error("Failed to connect to the Locust master: %s", e)
sys.exit(-1)

else:
runners.locust_runner = LocalLocustRunner(locust_classes, options)
# main_greenlet is pointing to runners.locust_runner.greenlet by default, it will point the web greenlet later if in web mode
main_greenlet = runners.locust_runner.greenlet

if options.no_web:
if options.master:
while len(runners.locust_runner.clients.ready) < options.expect_slaves:
logging.info("Waiting for slaves to be ready, %s of %s connected",
len(runners.locust_runner.clients.ready), options.expect_slaves)
time.sleep(1)
if options.step_time:
runners.locust_runner.start_stepload(options.num_clients, options.hatch_rate, options.step_clients, options.step_time)
elif not options.slave:
runners.locust_runner.start_hatching(options.num_clients, options.hatch_rate)
# make locusts are spawned
time.sleep(1)
elif not options.slave:
# spawn web greenlet
logger.info("Starting web monitor at http://%s:%s" % (options.web_host or "*", options.port))
main_greenlet = gevent.spawn(web.start, locust_classes, options)

if options.run_time:
spawn_run_time_limit_greenlet()

stats_printer_greenlet = None
if not options.only_summary and (options.print_stats or (options.no_web and not options.slave)):
# spawn stats printing greenlet
Expand Down
44 changes: 37 additions & 7 deletions locust/runners.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,11 @@ def __init__(self, locust_classes, options):
self.greenlet = self.locusts
self.state = STATE_INIT
self.hatching_greenlet = None
self.stepload_greenlet = None
self.exceptions = {}
self.stats = global_stats

self.step_load = options.step_load

# register listener that resets stats when hatching is complete
def on_hatch_complete(user_count):
self.state = STATE_RUNNING
Expand Down Expand Up @@ -208,6 +210,34 @@ def start_hatching(self, locust_count=None, hatch_rate=None, wait=False):
else:
self.spawn_locusts(wait=wait)

def start_stepload(self, locust_count, hatch_rate, step_locust_count, step_duration):
if locust_count < step_locust_count:
logger.error("Invalid parameters: total locust count of %d is smaller than step locust count of %d" % (locust_count, step_locust_count))
return
self.total_clients = locust_count
self.hatch_rate = hatch_rate
self.step_clients_growth = step_locust_count
self.step_duration = step_duration

if self.stepload_greenlet:
logger.info("There is an ongoing swarming in Step Load mode, will stop it now.")
self.greenlet.killone(self.stepload_greenlet)
logger.info("Start a new swarming in Step Load mode: total locust count of %d, hatch rate of %d, step locust count of %d, step duration of %d " % (locust_count, hatch_rate, step_locust_count, step_duration))
self.state = STATE_INIT
self.stepload_greenlet = self.greenlet.spawn(self.stepload_worker)
self.stepload_greenlet.link_exception(callback=self.noop)

def stepload_worker(self):
current_num_clients = 0
while self.state == STATE_INIT or self.state == STATE_HATCHING or self.state == STATE_RUNNING:
current_num_clients += self.step_clients_growth
if current_num_clients > int(self.total_clients):
logger.info('Step Load is finished.')
break
self.start_hatching(current_num_clients, self.hatch_rate)
logger.info('Step loading: start hatch job of %d locust.' % (current_num_clients))
gevent.sleep(self.step_duration)

def stop(self):
# if we are currently hatching locusts we need to kill the hatching greenlet first
if self.hatching_greenlet and not self.hatching_greenlet.ready():
Expand All @@ -227,6 +257,10 @@ def log_exception(self, node_id, msg, formatted_tb):
row["nodes"].add(node_id)
self.exceptions[key] = row

def noop(self, *args, **kwargs):
""" Used to link() greenlets to in order to be compatible with gevent 1.0 """
pass

class LocalLocustRunner(LocustRunner):
def __init__(self, locust_classes, options):
super(LocalLocustRunner, self).__init__(locust_classes, options)
Expand All @@ -250,10 +284,6 @@ def __init__(self, locust_classes, options):
self.master_bind_port = options.master_bind_port
self.heartbeat_liveness = options.heartbeat_liveness
self.heartbeat_interval = options.heartbeat_interval

def noop(self, *args, **kwargs):
""" Used to link() greenlets to in order to be compatible with gevent 1.0 """
pass

class SlaveNode(object):
def __init__(self, id, state=STATE_INIT, heartbeat_liveness=3):
Expand Down Expand Up @@ -291,7 +321,7 @@ def running(self):
self.greenlet = Group()
self.greenlet.spawn(self.heartbeat_worker).link_exception(callback=self.noop)
self.greenlet.spawn(self.client_listener).link_exception(callback=self.noop)

# listener that gathers info on how many locust users the slaves has spawned
def on_slave_report(client_id, data):
if client_id not in self.clients:
Expand Down Expand Up @@ -323,7 +353,7 @@ def start_hatching(self, locust_count, hatch_rate):
slave_hatch_rate = float(hatch_rate) / (num_slaves or 1)
remaining = locust_count % num_slaves

logger.info("Sending hatch jobs to %d ready clients", num_slaves)
logger.info("Sending hatch jobs of %d locusts and %.2f hatch rate to %d ready clients" % (slave_num_clients, slave_hatch_rate, num_slaves))

if self.state != STATE_RUNNING and self.state != STATE_HATCHING:
self.stats.clear_all()
Expand Down
16 changes: 14 additions & 2 deletions locust/templates/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
{% if is_distributed %}
<div class="top_box box_slaves" id="box_slaves">
<div class="label">SLAVES</div>
<div class="value" id="slaveCount">0</div>
<div class="value" id="slaveCount">{{slave_count}}</div>
</div>
{% endif %}
<div class="top_box box_rps box_running" id="box_rps">
Expand Down Expand Up @@ -58,7 +58,7 @@
<div class="padder">
<h2>Start new Locust swarm</h2>
<form action="./swarm" method="POST" id="swarm_form">
<label for="locust_count">Number of users to simulate</label>
<label for="locust_count">Number of total users to simulate</label>
<input type="text" name="locust_count" id="locust_count" class="val" /><br>
<label for="hatch_rate">Hatch rate <span style="color:#8a8a8a;">(users spawned/second)</span></label>
<input type="text" name="hatch_rate" id="hatch_rate" class="val" /><br>
Expand All @@ -69,6 +69,12 @@ <h2>Start new Locust swarm</h2>
{% endif %}
</label>
<input type="text" name="host" id="host" class="val" autocapitalize="off" autocorrect="off" value="{{ host or ""}}"/><br>
{% if is_step_load %}
<label for="step_locust_count">Number of users to increase by step</label>
<input type="text" name="step_locust_count" id="step_locust_count" class="val" /><br>
<label for="step_duration">Step duration <span style="color:#8a8a8a;">(300s, 20m, 3h, 1h30m, etc.)</span></label>
<input type="text" name="step_duration" id="step_duration" class="val" /><br>
{% endif %}
<button type="submit">Start swarming</button>
</form>
<div style="clear:right;"></div>
Expand All @@ -86,6 +92,12 @@ <h2>Change the locust count</h2>
<input type="text" name="locust_count" id="new_locust_count" class="val" /><br>
<label for="hatch_rate">Hatch rate <span style="color:#8a8a8a;">(users spawned/second)</span></label>
<input type="text" name="hatch_rate" id="new_hatch_rate" class="val" /><br>
{% if is_step_load %}
<label for="step_locust_count">Number of users to increase by step</label>
<input type="text" name="step_locust_count" id="step_locust_count" class="val" /><br>
<label for="step_duration">Step duration <span style="color:#8a8a8a;">(300s, 20m, 3h, 1h30m, etc.)</span></label>
<input type="text" name="step_duration" id="step_duration" class="val" /><br>
{% endif %}
<button type="submit">Start swarming</button>
</form>
<div style="clear:right;"></div>
Expand Down
38 changes: 36 additions & 2 deletions locust/test/test_runners.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,9 @@ def __init__(self):
self.master_bind_host = '*'
self.master_bind_port = 5557
self.heartbeat_liveness = 3
self.heartbeat_interval = 0.01
self.heartbeat_interval = 1
self.stop_timeout = None
self.step_load = True

def reset_stats(self):
pass
Expand Down Expand Up @@ -218,7 +219,7 @@ class MyTestLocust(Locust):
with mock.patch("locust.rpc.rpc.Server", mocked_rpc()) as server:
master = MasterLocustRunner(MyTestLocust, self.options)
server.mocked_send(Message("client_ready", None, "fake_client"))
sleep(0.1)
sleep(6)
# print(master.clients['fake_client'].__dict__)
assert master.clients['fake_client'].state == STATE_MISSING

Expand Down Expand Up @@ -413,6 +414,39 @@ class MyTestLocust(Locust):

self.assertEqual(2, num_clients, "Total number of locusts that would have been spawned is not 2")

def test_spawn_locusts_in_stepload_mode(self):
class MyTestLocust(Locust):
pass

with mock.patch("locust.rpc.rpc.Server", mocked_rpc()) as server:
master = MasterLocustRunner(MyTestLocust, self.options)
for i in range(5):
server.mocked_send(Message("client_ready", None, "fake_client%i" % i))

# start a new swarming in Step Load mode: total locust count of 10, hatch rate of 2, step locust count of 5, step duration of 5s
master.start_stepload(10, 2, 5, 5)

# make sure the first step run is started
sleep(1)
self.assertEqual(5, len(server.outbox))

num_clients = 0
end_of_last_step = len(server.outbox)
for _, msg in server.outbox:
num_clients += msg.data["num_clients"]

self.assertEqual(5, num_clients, "Total number of locusts that would have been spawned for first step is not 5")

# make sure the first step run is complete
sleep(5)
num_clients = 0
idx = end_of_last_step
while idx < len(server.outbox):
msg = server.outbox[idx][1]
num_clients += msg.data["num_clients"]
idx += 1
self.assertEqual(10, num_clients, "Total number of locusts that would have been spawned for second step is not 10")

def test_exception_in_task(self):
class HeyAnException(Exception):
pass
Expand Down
8 changes: 7 additions & 1 deletion locust/test/test_web.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
ALTERNATIVE_HOST = 'http://localhost'
SWARM_DATA_WITH_HOST = {'locust_count': 5, 'hatch_rate': 5, 'host': ALTERNATIVE_HOST}
SWARM_DATA_WITH_NO_HOST = {'locust_count': 5, 'hatch_rate': 5}

SWARM_DATA_WITH_STEP_LOAD = {"locust_count":5, "hatch_rate":2, "step_locust_count":2, "step_duration": "2m"}

class TestWebUI(LocustTestCase):
def setUp(self):
Expand Down Expand Up @@ -210,3 +210,9 @@ class MyLocust2(Locust):
self.assertEqual(200, response.status_code)
self.assertNotIn("http://example.com", response.content.decode("utf-8"))
self.assertIn("setting this will override the host on all Locust classes", response.content.decode("utf-8"))

def test_swarm_in_step_load_mode(self):
runners.locust_runner.step_load = True
response = requests.post("http://127.0.0.1:%i/swarm" % self.web_port, SWARM_DATA_WITH_STEP_LOAD)
self.assertEqual(200, response.status_code)
self.assertIn("Step Load Mode", response.text)
14 changes: 13 additions & 1 deletion locust/web.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
from .stats import failures_csv, median_from_dict, requests_csv, sort_stats, stats_history_csv
from .util.cache import memoize
from .util.rounding import proper_round
from .util.timespan import parse_timespan

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -60,23 +61,34 @@ def index():
else:
host = None

is_step_load = runners.locust_runner.step_load

return render_template("index.html",
state=runners.locust_runner.state,
is_distributed=is_distributed,
user_count=runners.locust_runner.user_count,
version=version,
host=host,
override_host_warning=override_host_warning,
slave_count=slave_count,
is_step_load=is_step_load
)

@app.route('/swarm', methods=["POST"])
def swarm():
assert request.method == "POST"

is_step_load = runners.locust_runner.step_load
locust_count = int(request.form["locust_count"])
hatch_rate = float(request.form["hatch_rate"])
if (request.form.get("host")):
runners.locust_runner.host = str(request.form["host"])

if is_step_load:
step_locust_count = int(request.form["step_locust_count"])
step_duration = parse_timespan(str(request.form["step_duration"]))
runners.locust_runner.start_stepload(locust_count, hatch_rate, step_locust_count, step_duration)
return jsonify({'success': True, 'message': 'Swarming started in Step Load Mode', 'host': runners.locust_runner.host})

runners.locust_runner.start_hatching(locust_count, hatch_rate)
return jsonify({'success': True, 'message': 'Swarming started', 'host': runners.locust_runner.host})

Expand Down