Beefy Boxes and Bandwidth Generously Provided by pair Networks
good chemistry is complicated,
and a little bit messy -LW

comment on

( #3333=superdoc: print w/replies, xml ) Need Help??

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.

In reply to Applied MCE : Building a load-testing framework for PSGI apps with Plack::Test and MCE::Queue by 1nickt

Use:  <p> text here (a paragraph) </p>
and:  <code> code here </code>
to format your post; it's "PerlMonks-approved HTML":

  • Posts are HTML formatted. Put <p> </p> tags around your paragraphs. Put <code> </code> tags around your code and data!
  • Titles consisting of a single word are discouraged, and in most cases are disallowed outright.
  • Read Where should I post X? if you're not absolutely sure you're posting in the right place.
  • Please read these before you post! —
  • Posts may use any of the Perl Monks Approved HTML tags:
    a, abbr, b, big, blockquote, br, caption, center, col, colgroup, dd, del, div, dl, dt, em, font, h1, h2, h3, h4, h5, h6, hr, i, ins, li, ol, p, pre, readmore, small, span, spoiler, strike, strong, sub, sup, table, tbody, td, tfoot, th, thead, tr, tt, u, ul, wbr
  • You may need to use entities for some characters, as follows. (Exception: Within code tags, you can put the characters literally.)
            For:     Use:
    & &amp;
    < &lt;
    > &gt;
    [ &#91;
    ] &#93;
  • Link using PerlMonks shortcuts! What shortcuts can I use for linking?
  • See Writeup Formatting Tips and other pages linked from there for more info.
  • Log In?

    What's my password?
    Create A New User
    and the web crawler heard nothing...

    How do I use this? | Other CB clients
    Other Users?
    Others chanting in the Monastery: (3)
    As of 2020-11-28 17:21 GMT
    Find Nodes?
      Voting Booth?

      No recent polls found