If you develop PSGI apps in Dancer2, Mojolicious, Plack or something else, you probably know that it's fairly easy to test all your routes and confirm that they function as expected. But what about the performance of your app under load? Will there be any bottlenecks? Can the database support the concurrent connections you plan to open? Do you have enough, or too many, workers serving requests? Etc.
The following script shows how to build a load-testing framework for your PSGI app using Plack::Test for the scaffold and MCE::Flow and MCE::Queue to create the concurrent traffic. (The example includes a Dancer2 app to be exercised, but you can of course use your own app class from outside or inside the script, updating the test flow appropriately.)
The demonstration simulates an API for managing account records that exposes three endpoints: / POST, / GET, / PUT. The workflow/account lifespan is typical: the caller first creates an account record via a POST call, which returns an id that must be used for future calls. The demonstration simulates the caller encountering the account record still with status 'pending' on the first GET call, and having to call again until it is activated. Finally, the caller deletes the account.
The script is configured to create 1,000 accounts shared among 10 users. (Note that this is an example and includes no parameter checking, error handling, etc. For demonstration purposes only.) The example app creates an SQLite database on disk.
How it works
The script uses MCE::Flow to simulate the workflow, and MCE::Queue to manage the jobs. Two user subroutines are defined, one for the queue "producer" and one for the queue "consumers." The "producer" represents callers making the initial requests to create new accounts; these are placed onto the queue at small intervals. The "consumers" of the queue represent callers making subsequent requests to the app and reacting to the response, sometimes by making a new request. In that case a job for the new request is placed on the queue before the "consumer" finishes the current job.
To tune the test, change the number of consumers, add more accounts to be created, or reduce/increase the sleeps representing work being performed and caller latency.
To run the test
Just copy the code, install the dependencies, and run. You may wish to tail the log file (by default at /tmp/test.log) to see what the app is doing. Afterwards you may wish to examine the populated database (by default at /tmp/test.sqlite), as it will be overwritten on the next run.
script to load-test/profile a PSGI application
# script to load-test/profile a PSGI application
use strict; use warnings;
use 5.010;
#---------------------------------------------------------------------
+#
# Some configs you might want to change
my ($log_file, $db_file, $consumers);
BEGIN {
$log_file = '/tmp/test.log';
$db_file = '/tmp/test.sqlite';
$consumers = 8;
}
#---------------------------------------------------------------------
+#
# Load libraries needed by test framework
use Data::GUID;
use DBD::SQLite;
use HTTP::Request::Common;
use JSON::MaybeXS;
use Log::Any '$log';
use Log::Any::Adapter File => $log_file;
use MCE::Flow;
use MCE::Queue;
use Plack::Test;
use Tie::Cycle;
use Time::HiRes 'usleep';
#---------------------------------------------------------------------
+#
# Define the web application
package MyApp {
use Dancer2;
use Dancer2::Plugin::Database;
unlink $db_file;
#-----------------------------------------------------------------
+#
# Config
set charset => 'UTF-8';
set serializer => 'JSON';
set engines => {
logger => {
LogAny => {
category => 'Test',
logger => ['File', $log_file],
},
},
};
set logger => 'LogAny';
set log => 'debug';
set plugins => {
Database => {
driver => 'SQLite',
database => $db_file,
dbi_params => { RaiseError => 1, AutoCommit => 1 },
},
};
#-----------------------------------------------------------------
+#
# DB schema
database->do(q{
CREATE TABLE account(
account_id INTEGER PRIMARY KEY,
user_id VARCHAR(36) NOT NULL,
caller_id VARCHAR(36) NOT NULL,
remote_id VARCHAR(36) DEFAULT NULL,
status TEXT
CHECK( status IN ('pending','active','deleted') )
NOT NULL DEFAULT 'pending'
);
});
#-----------------------------------------------------------------
+#
# Route handlers
##
post '/' => sub {
my $user_id = body_parameters->get('user_id');
my $caller_id = body_parameters->get('caller_id');
my $wid = body_parameters->get('wid');
my $remote_id = Data::GUID->new->as_string;
debug " app: $wid: handling POST with caller_id $caller_id";
debug " app: $wid: generated remote_id $remote_id";
database->quick_insert('account', {
user_id => $user_id,
caller_id => $caller_id,
remote_id => $remote_id,
status => 'pending',
});
return {remote_id => $remote_id, status => 'pending'};
};
##
get '/:remote_id/:wid' => sub {
my $remote_id = route_parameters->get('remote_id');
my $wid = route_parameters->get('wid');
my $record = database->quick_select('account', {
remote_id => $remote_id,
});
debug " app: $wid: handling GET with remote_id $remote_id";
# simulate a needed external call or some other delay before
# account activation; return status pending on first attempt
if ( $record->{status} eq 'pending' ) {
debug " app: $wid: found account (remote_id $remote_id)" .
"has status pending; updating";
database->quick_update('account',
{remote_id => $remote_id }, { status => 'active' } );
}
return {remote_id => $remote_id, status => $record->{status}};
};
##
put '/:remote_id/:wid' => sub {
my $remote_id = route_parameters->get('remote_id');
my $wid = route_parameters->get('wid');
debug " app: $wid: handling PUT with remote_id $remote_id";
database->quick_update('account',
{ remote_id => $remote_id }, { status => 'deleted' });
};
};
# End web application definition
#---------------------------------------------------------------------
+#
# Test code
my $app = MyApp->to_app;
test_psgi $app, sub {
my $cb = shift;
my $q = MCE::Queue->new( await => 1, fast => 0 );
tie my $user_id, 'Tie::Cycle',
[ map { Data::GUID->new->as_string } 1..10 ];
#-----------------------------------------------------------------
+#
# Caller workflow
mce_flow {
task_name => [ 'producer', 'consumer' ],
max_workers => [ 1, $consumers ],
}, sub {
## producer
for my $idx (1 .. 1_000) {
my $wid = MCE->wid;
$log->debug("test: $wid: creating account #$idx");
usleep(10_000); # simulate staggered requests
my $caller_id = Data::GUID->new->as_string;
my $content = encode_json({
wid => $wid,
user_id => $user_id,
caller_id => $caller_id,
});
$log->debug("test: $wid: enqueueing post job " .
"for account #$idx");
$q->enqueue([ post => { content => $content } ]);
}
$q->enqueue('done'); # trigger to signal end of work
},
sub {
## consumers
my $done = 0;
while (1) {
my $job = $q->dequeue_nb;
my $wid = MCE->wid;
# Handle end of work
if ( ! defined $job ) {
if ( $done ) {
$log->debug("test: $wid: found no job in queue");
$q->enqueue('done');
last;
}
usleep(30_000);
next;
}
if ( $job eq 'done' ) {
$done = 1;
usleep(30_000);
next;
}
# do work
my ($method, $args) = @{ $job };
if ( $method eq 'post' ) {
$log->debug("test: $wid: dequeued POST job");
my $header = [
'Content-Type' => 'application/json; charset=UTF-8
+'
];
my $body = $args->{content};
my $resp = $cb->(POST '/', $header, Content => $body);
my $resp_content = decode_json($resp->decoded_content)
+;
my $remote_id = $resp_content->{remote_id};
$log->debug("test: $wid: created account " .
"(remote_id $remote_id; enqueueing GET call");
usleep(15_000); # simulate staggered requests
$q->enqueue([ get => {
wid => $wid, remote_id => $remote_id,
}]);
}
elsif ( $method eq 'get' ) {
my $remote_id = $args->{remote_id};
$log->debug("test: $wid: dequeued GET job for " .
"remote_id $remote_id");
my $resp = $cb->(GET "/$remote_id/$wid");
my $status =
decode_json( $resp->decoded_content )->{status};
if ( $status eq 'pending' ) {
$log->debug("test: $wid: re-enqueueing GET job " .
"for remote id $remote_id");
usleep(15_000); # simulate staggered requests
$q->enqueue([ get => {
wid => $wid, remote_id => $remote_id,
}]);
}
else {
$log->debug("test: $wid: enqueueing PUT job for" .
" remote_id $remote_id");
usleep(15_000); # simulate staggered requests
$q->enqueue([ put => {
wid => $wid, remote_id => $remote_id,
}]);
}
}
elsif ( $method eq 'put' ) {
my $remote_id = $args->{remote_id};
$log->debug("test: $wid: dequeued PUT job for " .
"remote_id $remote_id");
my $resp = $cb->( PUT "/$remote_id/$wid" );
}
else {
$log->error('test: queue job HTTP method unknown!');
}
}
};
MCE::Flow->finish;
};
__END__
Some sample output:
...
test: 1: enqueueing post job for account #969
test: 1: creating account #970
test: 3: re-enqueueing GET job for remote id 885E60E6-7291-11EA-966F-6
+BCA807F5374
test: 8: dequeued POST job
app: 1: handling POST with caller_id 886169E4-7291-11EA-966F-69CA807F
+5374
app: 1: generated remote_id 8861B2B4-7291-11EA-966F-70CA807F5374
test: 8: created account (remote_id 8861B2B4-7291-11EA-966F-70CA807F53
+74; enqueueing GET call
test: 6: dequeued PUT job for remote_id 8858701E-7291-11EA-966F-6BCA80
+7F5374
test: 9: dequeued PUT job for remote_id 88599AB6-7291-11EA-966F-6ECA80
+7F5374
app: 9: handling PUT with remote_id 88599AB6-7291-11EA-966F-6ECA807F5
+374
app: 6: handling PUT with remote_id 8858701E-7291-11EA-966F-6BCA807F5
+374
test: 9: dequeued GET job for remote_id 885CC7F4-7291-11EA-966F-71CA80
+7F5374
app: 9: handling GET with remote_id 885CC7F4-7291-11EA-966F-71CA807F5
+374
test: 9: enqueueing PUT job for remote_id 885CC7F4-7291-11EA-966F-71CA
+807F5374
test: 1: enqueueing post job for account #970
...
The way forward always starts with a minimal test.
-
Are you posting in the right place? Check out Where do I post X? to know for sure.
-
Posts may use any of the Perl Monks Approved HTML tags. Currently these include the following:
<code> <a> <b> <big>
<blockquote> <br /> <dd>
<dl> <dt> <em> <font>
<h1> <h2> <h3> <h4>
<h5> <h6> <hr /> <i>
<li> <nbsp> <ol> <p>
<small> <strike> <strong>
<sub> <sup> <table>
<td> <th> <tr> <tt>
<u> <ul>
-
Snippets of code should be wrapped in
<code> tags not
<pre> tags. In fact, <pre>
tags should generally be avoided. If they must
be used, extreme care should be
taken to ensure that their contents do not
have long lines (<70 chars), in order to prevent
horizontal scrolling (and possible janitor
intervention).
-
Want more info? How to link
or How to display code and escape characters
are good places to start.