#!/usr/bin/env perl # SPDX-FileCopyrightText: 2026 GPForum Contributors # SPDX-License-Identifier: BSD-3-Clause package main; use v5.40; use Carp qw(croak); use Const::Fast; use Config qw(%Config); use DBI; use English qw(-no_match_vars); use FindBin qw($Bin); use Getopt::Long qw(GetOptionsFromArray); use IO::Select; use IO::Socket::IP; use IO::Socket::UNIX; use File::Temp qw(tempdir); use JSON::MaybeXS qw(encode_json); use MIME::Base64 qw(encode_base64url); use POSIX qw(_exit clock setsid strftime); use Time::HiRes qw(sleep time); use lib "$Bin/../lib"; use lib "$Bin/../t/lib"; use lib 't/lib'; use GPForum::Test::PgSchema; use GPForum::Benchmark::BucketProfile; use GPForum::Benchmark::SeedDataset qw(seed_id); use GPForum::Command::Migrate; use GPForum::Command::PerformanceSeed; use GPForum::Config; use GPForum::Infrastructure::Id; use GPForum::Infrastructure::SqlFunction; use GPForum::Schema; use GPForum::Service::Admin::PermissionGate; use GPForum::Service::Forum::ViewerResolver; use GPForum::Service::Identity::SessionStore; use GPForum::Service::Moderation::SuspensionStore; use GPForum::Service::Operations::RateLimiter::PostgreSQLStore; use GPForum::Service::Forum::ThreadReader; use GPForum::Service::Forum::HomePageReader; use GPForum::Service::Forum::Readability; use GPForum::Service::Forum::ReadState; use GPForum::Service::Forum::ReadWorkflow; use GPForum::Service::Search::Indexer; use GPForum::Service::Search::Searcher; use GPForum::Service::Search::PermissionEngine; use GPForum::Service::Notification::Dispatcher; use GPForum::Service::Community::MentionStore; use GPForum::Service::Forum::CategoryReader; use GPForum::Service::Forum::PostComposer; use GPForum::Service::Forum::PostReader; use GPForum::Service::Forum::PostStore; use GPForum::Service::Forum::PostingWorkflow; use GPForum::Service::Forum::ThreadComposer; use GPForum::Service::Forum::ThreadDetailReader; use GPForum::Service::Forum::ThreadStore; use GPForum::Service::Forum::Viewer; use GPForum::Service::Operations::CommandIdempotency; our $VERSION = '0.001'; # ADR 0126: what an operation costs on the path DBIx::Class builds and on # the PostgreSQL function that replaces it, on the same seeded database. Per # operation and path: the Perl CPU, the share of it spent in DBIx::Class # (SQL::Abstract building the SQL, resultsets, storage, row inflation, # accessors) against the time spent waiting on DBI, the wall time's median # and 95th percentile with the database on this host and through a proxy # that delays each direction (a database on another host), and the # statements and round trips. A maintainer's tool: it makes a database of # its own, seeds it as t/integration/postgres-query-plan-depth.t does, and # drops it. const my %DEFAULT => ( 'delay-ms' => 0.5, 'read-iterations' => 200, 'warmup' => 15, 'write-iterations' => 100, ); const my $THREAD_POSTS => 3_000; const my $SEEDED_POSTS => 8; const my $CATEGORY_THREADS => 1_500; const my $NOTIFICATIONS => 30; const my $HALFWAY => 1_500; const my $MILLISECONDS => 1_000; const my $CLOCKS_PER_SECOND => 1_000_000; const my $PERCENTILE => 0.95; const my $SELECT_ONE_RUNS => 2_000; const my $LISTEN_QUEUE => 16; const my $CHUNK => 65_536; const my @PATHS => qw(dbic function); const my @MODES => qw(direct proxy); const my @ROUND_TRIPS => qw( begin_work commit rollback pg_savepoint pg_release pg_rollback_to ); # The configuration benchmark explicitly loads this workload as a library. # Other callers (including failure-injection drills using do) still run it. our $LOAD_AS_LIBRARY; exit main(@ARGV) if !$LOAD_AS_LIBRARY; sub main (@arguments) { my %option = %DEFAULT; GetOptionsFromArray( \@arguments, \%option, 'delay-ms=f', 'json', 'read-iterations=i', 'warmup=i', 'write-iterations=i', 'help' ) or return _usage(2); return _usage(0) if $option{help}; return _usage(2) if @arguments || !_valid_options( \%option ); my $admin_dsn = $ENV{GPFORUM_DATABASE_DSN} // croak 'set GPFORUM_DATABASE_DSN to a database the user may CREATE in'; my $name = "gpforum_bench_sqlfn_$PROCESS_ID"; my $admin = _connect($admin_dsn); my $proxy; my $report; $admin->do("CREATE DATABASE $name"); try { local $SIG{TERM} = sub { die "SQL function benchmark interrupted by TERM\n"; }; local $SIG{INT} = sub { die "SQL function benchmark interrupted by INT\n"; }; ( my $dsn = $admin_dsn ) =~ s/dbname=[^;]+/dbname=$name/msx; _seed($dsn); $proxy = _proxy( $dsn, $option{'delay-ms'} / $MILLISECONDS ); $report = _measure( $dsn, $proxy, \%option ); $report->{benchmark} = { database_name => $name, generated_at => strftime( '%Y-%m-%dT%H:%M:%SZ', gmtime ), perl_version => "$PERL_VERSION", architecture => $Config{archname}, postgres_version => scalar $admin->selectrow_array('SHOW server_version'), read_iterations => $option{'read-iterations'}, write_iterations => $option{'write-iterations'}, warmup => $option{warmup}, scope => 'service operations; excludes HTTP dispatch and template rendering', workload => 'small performance seed plus 3000 posts with bodies, 1500 indexed threads and 30 notifications', }; } catch ($error) { _stop($proxy); $admin->do("DROP DATABASE IF EXISTS $name WITH (FORCE)"); croak $error; }; _stop($proxy); $admin->do("DROP DATABASE IF EXISTS $name WITH (FORCE)"); $admin->disconnect; print $option{json} ? encode_json($report) . "\n" : _text($report) or croak 'cannot write the report'; return 0; } sub _valid_options ($option) { return $option->{'read-iterations'} >= 1 && $option->{'write-iterations'} >= 1 && $option->{warmup} >= 0 && $option->{'delay-ms'} >= 0; } sub _usage ($status) { print 'Usage: script/bench-sql-functions [--read-iterations N]' . " [--write-iterations N] [--warmup N] [--delay-ms MS] [--json]\n" or croak 'cannot write the usage'; return $status; } sub _connect ($dsn) { return DBI->connect( $dsn, $ENV{GPFORUM_DATABASE_USER}, $ENV{GPFORUM_DATABASE_PASSWORD}, { AutoCommit => 1, PrintError => 0, RaiseError => 1 }, ); } # The plan tests' seed, and what t/integration/postgres-query-plan-depth.t # adds -- 3,000 posts in the first thread and 1,500 threads in the first # category -- the posts with bodies, so a page renders full posts. sub _seed ($dsn) { local $ENV{GPFORUM_DATABASE_DSN} = $dsn; my $quiet = sub ($code) { my $output = q{}; open my $sink, '>', \$output or croak "capture: $OS_ERROR"; my $status; { local *STDOUT = $sink; $status = $code->(); } close $sink or croak "capture: $OS_ERROR"; return $status; }; croak 'migrations failed' if $quiet->( sub { GPForum::Command::Migrate->new->run('--apply') } ); croak 'seed failed' if $quiet->( sub { GPForum::Command::PerformanceSeed->new->run( '--profile', 'small' ); } ); my $dbh = _connect($dsn); my ( $thread, $category, $author ) = ( seed_id( thread => 1 ), seed_id( category => 1 ), seed_id( user => 2 ), ); $dbh->do( q{INSERT INTO posts (post_id, thread_id, author_user_id, position)} . q{ SELECT gen_random_uuid(), ?, ?, n} . q{ FROM generate_series(?::bigint, ?::bigint) AS n}, undef, $thread, $author, $SEEDED_POSTS + 1, $SEEDED_POSTS + $THREAD_POSTS ); $dbh->do( q{WITH b AS (INSERT INTO post_bodies (body_id, post_id, body_format,} . q{ body_source, body_rendered_safe, source_hash)} . q{ SELECT gen_random_uuid(), p.post_id, 'markdown',} . q{ 'Reply number ' || p.position || ' with a little text.',} . q{ '

Reply number ' || p.position || ' with a little text.

',} . q{ md5(p.post_id::text) FROM posts p WHERE p.thread_id = ?} . q{ AND p.current_body_id IS NULL RETURNING body_id, post_id)} . q{ UPDATE posts p SET current_body_id = b.body_id FROM b} . q{ WHERE b.post_id = p.post_id}, undef, $thread ); $dbh->do( q{INSERT INTO threads (thread_id, category_id, author_user_id, title,} . q{ slug, last_activity_at) SELECT gen_random_uuid(), ?, ?,} . q{ 'Depth ' || n, 'depth-' || n, timestamptz '2026-01-01'} . q{ + n * interval '1 minute' FROM generate_series(1, ?) AS n}, undef, $category, $author, $CATEGORY_THREADS ); my $index_schema = _schema($dsn); my $indexer = GPForum::Service::Search::Indexer->new( schema => $index_schema ); for my $id ( @{ $dbh->selectcol_arrayref( q{SELECT thread_id FROM threads WHERE category_id = ? AND slug LIKE 'depth-%'}, undef, $category ) } ) { $indexer->index_thread($id); } $index_schema->storage->disconnect; $dbh->do('ANALYZE'); $dbh->disconnect; return; } # A Unix socket proxy that holds each chunk for the delay before passing it on, in # both directions: a database on another host, as far as round trips go. # One process listens; one per connection forwards. sub _proxy ( $dsn, $delay ) { my $connection = _connect($dsn); my ( $host, $port ) = @{$connection}{qw(pg_host pg_port)}; $connection->disconnect; croak 'The SQL benchmark needs a host in the DSN: a socket directory' . ' or a TCP host' if !defined $host || !length $host; # The proxy listens on a Unix socket either way; it reaches PostgreSQL # the way the DSN does, so a TCP database is measured too. my $upstream = $host =~ m{\A /}msx ? { Peer => "$host/.s.PGSQL.$port" } : { PeerHost => $host, PeerPort => $port }; my $directory = tempdir( 'gpforum-sql-proxy-XXXXXX', DIR => '/tmp', CLEANUP => 1 ); my $socket = "$directory/.s.PGSQL.$port"; my $listener = IO::Socket::UNIX->new( Listen => $LISTEN_QUEUE, Local => $socket, ) or croak "proxy listen: $OS_ERROR"; my $pid = fork // croak "fork: $OS_ERROR"; if ( $pid == 0 ) { # Only the measuring parent owns the temporary database. Proxy # children must terminate directly instead of unwinding its cleanup. local $SIG{TERM} = 'DEFAULT'; local $SIG{INT} = 'DEFAULT'; local $SIG{CHLD} = 'IGNORE'; setsid() >= 0 or _exit(1); while ( my $client = $listener->accept ) { my $child = fork // croak "fork: $OS_ERROR"; if ( $child == 0 ) { close $listener or _exit(1); _forward( $client, $upstream, $delay ); _exit(0); } close $client or croak "close: $OS_ERROR"; } _exit(0); } close $listener or croak "close: $OS_ERROR"; ( my $through = $dsn ) =~ s/(?: ; )? (?: host | port ) = [^;]*//gmsx; $through .= ";host=$directory;port=$port"; return { dsn => $through, pid => $pid, directory => $directory, socket => $socket }; } sub _forward ( $client, $upstream, $delay ) { my $server = ( exists $upstream->{Peer} ? IO::Socket::UNIX->new( %{$upstream} ) : IO::Socket::IP->new( %{$upstream} ) ) or _exit(1); my %peer = ( $client => $server, $server => $client ); my $select = IO::Select->new( $client, $server ); while ( my @ready = $select->can_read ) { for my $from (@ready) { my $chunk; my $read = sysread $from, $chunk, $CHUNK; return if !$read; sleep $delay; my $to = $peer{$from}; my $written = 0; while ( $written < length $chunk ) { my $sent = syswrite $to, $chunk, length($chunk) - $written, $written; return if !$sent; $written += $sent; } } } return; } sub _stop ($proxy) { return if !$proxy; kill 'TERM', -$proxy->{pid}; waitpid $proxy->{pid}, 0; unlink $proxy->{socket} or croak "remove proxy socket: $OS_ERROR"; rmdir $proxy->{directory} or croak "remove proxy directory: $OS_ERROR"; return; } sub _schema ($dsn) { local $ENV{GPFORUM_DATABASE_DSN} = $dsn; return GPForum::Test::PgSchema->connect_from_config( GPForum::Config->from_environment ); } # Every operation on each path, at the database and through the proxy, # then the profile of each at the database. sub _measure ( $dsn, $proxy, $option ) { my %report = ( delay_ms => $option->{'delay-ms'}, round_trip_ms => { direct => _select_one($dsn), proxy => _select_one( $proxy->{dsn} ), }, ); for my $mode (@MODES) { my $schema = _schema( $mode eq 'direct' ? $dsn : $proxy->{dsn} ); my $counts = _counted( $schema->storage->dbh ); for my $path (@PATHS) { my $ops = _operations( $schema, $path ); for my $op ( sort keys %{$ops} ) { $report{operations}{$op}{$path}{$mode} = _timed( $ops->{$op}, $counts, $option ); } } $schema->storage->disconnect; } my $schema = _schema($dsn); for my $path (@PATHS) { my $ops = _operations( $schema, $path ); for my $op ( sort keys %{$ops} ) { $report{operations}{$op}{$path}{profile} = _profiled( $ops->{$op}, $option ); } } $schema->storage->disconnect; return \%report; } sub _select_one ($dsn) { my $dbh = _connect($dsn); my $one = $dbh->prepare('SELECT 1'); my @runs; for ( 1 .. $SELECT_ONE_RUNS ) { my $started = time; $one->execute; $one->fetchall_arrayref; push @runs, time - $started; } $dbh->disconnect; return _ms( _median(@runs) ); } # The operations, each a code reference that runs one of them, built as # Bootstrap::Forum builds the services. The dbic path is the one a schema # without the function layer takes; the function path the one PostgreSQL # takes now. sub _operation_path ( $path, $context ) { return ( functions => undef ) if $path eq 'dbic'; return ( functions => $context->{functions} ) if $context->{functions}; return (); } sub _operations ( $schema, $path, $context = {} ) { my %perl = _operation_path( $path, $context ); # The services with one body per operation (ADR 0177) have no DBIx path: # both paths measure their functions, the call layer built here, before # the dbic path hides the function routing. my %one = ( functions => $context->{functions} // GPForum::Infrastructure::SqlFunction->for_schema($schema) ); my $thread = seed_id( thread => 1 ); my $member = GPForum::Service::Forum::Viewer->new( member => 1, user_id => seed_id( user => 2 ), ); my $posts = GPForum::Service::Forum::PostReader->new( schema => $schema, %one ); my $threads = GPForum::Service::Forum::ThreadDetailReader->new( post_reader => $posts, schema => $schema, %one, ); my $workflow = GPForum::Service::Forum::PostingWorkflow->new( category_reader => GPForum::Service::Forum::CategoryReader->new( schema => $schema, %one ), command_idempotency => GPForum::Service::Operations::CommandIdempotency->new( schema => $schema ), mention_store => GPForum::Service::Community::MentionStore->new( schema => $schema ), post_composer => GPForum::Service::Forum::PostComposer->new, post_store => GPForum::Service::Forum::PostStore->new( schema => $schema, %one ), thread_composer => GPForum::Service::Forum::ThreadComposer->new, thread_detail_reader => $threads, thread_store => GPForum::Service::Forum::ThreadStore->new( schema => $schema, %one ), ); my $ids = GPForum::Infrastructure::Id->new; my ($halfway) = $schema->storage->dbh->selectrow_array( 'SELECT post_id FROM posts WHERE thread_id = ? AND position = ?', undef, $thread, $HALFWAY ); my $deep = encode_base64url("$HALFWAY|$halfway"); my $categories = GPForum::Service::Forum::CategoryReader->new( schema => $schema, %one ); my $lists = GPForum::Service::Forum::ThreadReader->new( schema => $schema, %one ); my $home = GPForum::Service::Forum::HomePageReader->new( category_reader => $categories, thread_reader => $lists ); my $category = seed_id( category => 1 ); my $scope = $member->within( $category, seed_id( space => 1 ) ); my $sessions = GPForum::Service::Identity::SessionStore->new( schema => $schema, %perl, ); my $session = $sessions->create_session( { id => $member->user_id }, { request_address => '198.51.100.19', user_agent => 'SQL function benchmark' } ); my $session_request = { session_id => $session->{session}->get_column('session_id'), session_token => $session->{session_token}, user_id => $member->user_id, }; my $viewer_resolver = GPForum::Service::Forum::ViewerResolver->new( schema => $schema, %one, ); my $participation = GPForum::Service::Moderation::SuspensionStore->new( schema => $schema, %one, ); my $permission = GPForum::Service::Admin::PermissionGate->new( schema => $schema, %perl, ); my $rate_limit = GPForum::Service::Operations::RateLimiter::PostgreSQLStore->new( schema => $schema, %perl, ); $schema->storage->dbh->do( 'DELETE FROM rate_limit_buckets WHERE scope = ?', undef, 'sql_fn_bench' ); my $rate_request = { scope => 'sql_fn_bench', action => 'read', actor_id => $member->user_id, limit => 100_000_000, }; my $notifications = GPForum::Service::Notification::Dispatcher->new( schema => $schema, %one, readability => GPForum::Service::Forum::Readability->new( schema => $schema, viewer_resolver => $viewer_resolver, %one ) ); if ( !$schema->resultset('NotificationInbox') ->search( { recipient_user_id => $member->user_id } ) ->count ) { for my $index ( 1 .. $NOTIFICATIONS ) { $notifications->create_notification( { recipient_user_id => $member->user_id, notification_id => $ids->uuid, source_type => 'thread', source_id => $thread, notification_type => 'reply', payload => { index => $index } } ); } } my $searcher = GPForum::Service::Search::Searcher->new( schema => $schema, %one, permission_engine => GPForum::Service::Search::PermissionEngine->new, ); my $read_state = GPForum::Service::Forum::ReadState->new( schema => $schema, %one ); my $read_marks = GPForum::Service::Forum::ReadWorkflow->new( command_idempotency => GPForum::Service::Operations::CommandIdempotency->new( schema => $schema ), read_state => $read_state, ); my $operations = { %{ _write_operations( $workflow, $read_marks, $rate_limit, $rate_request, $member, $thread, $halfway, $category, $ids, $context->{edit_counter} ) }, search_ranked => { kind => 'read', run => sub { my $result = $searcher->ranked_search( { viewer => $member }, 'Depth', { limit => 20 } ); @{ $result->{results} } or croak 'benchmark search found no documents'; return $result; } }, search_autocomplete => { kind => 'read', run => sub { my $result = $searcher->autocomplete( { viewer => $member }, 'Dep', { limit => 10 } ); @{$result} or croak 'benchmark autocomplete found no documents'; return $result; } }, category_index_member => { kind => 'read', run => sub { $categories->list_categories( { viewer => $member } ); }, }, category_threads_member => { kind => 'read', run => sub { $lists->list_category_threads( { category_id => $category, viewer_scope => $scope, viewer_user_id => $member->user_id, limit => 25, } ); }, }, latest_threads_member => { kind => 'read', run => sub { $lists->list_public_threads( { viewer => $member, limit => 25 } ); }, }, home_page_member => { kind => 'read', run => sub { $home->home_page( { viewer => $member } ); }, }, session_validate => { kind => 'read', run => sub { $sessions->validate_session($session_request)->{ok} or croak 'benchmark session was refused'; }, }, viewer_resolve => { kind => 'read', run => sub { $viewer_resolver->resolve( $member->user_id )->member or croak 'benchmark viewer is not a member'; }, }, participation => { kind => 'read', run => sub { $participation->can_participate( $member->user_id )->{ok} or croak 'benchmark member cannot participate'; }, }, permission_check => { kind => 'read', run => sub { $permission->allowed( $member->user_id, { resource_type => 'category', action => 'read' } ); }, }, notification_inbox => { kind => 'read', run => sub { $notifications->list_page_for_user( $member->user_id, { limit => 25, viewer => $member } ); } }, notification_badge => { kind => 'read', run => sub { $notifications->unread_count_for_user( $member->user_id, $member ); } }, thread_page_anonymous => { kind => 'read', run => sub { $threads->thread_page( { thread_id => $thread } )->{ok} or croak 'no thread page'; }, }, thread_page_member_deep => { kind => 'read', run => sub { $threads->thread_page( { after => $deep, thread_id => $thread, viewer => $member, viewer_user_id => $member->user_id, } )->{ok} or croak 'no thread page'; }, }, thread_page_member => { kind => 'read', run => sub { $threads->thread_page( { thread_id => $thread, viewer => $member, viewer_user_id => $member->user_id, } )->{ok} or croak 'no thread page'; }, }, }; return _with_path( $operations, $path ); } sub _write_operations ( $workflow, $read_marks, $rate_limit, $rate_request, $member, $thread, $halfway, $category, $ids, $edit_counter = undef ) { my $marked_position = $read_marks->read_state->state_for_thread( $member->user_id, $thread ) ->{last_read_position}; my $edit_number = 0; $edit_counter //= \$edit_number; return { mark_read => { kind => 'write', run => sub { my $result = $read_marks->mark_thread_read( { command_id => $ids->uuid, user_id => $member->user_id, thread_id => $thread, last_read_position => ++$marked_position } ); $result->{ok} or croak 'benchmark mark read failed'; } }, post_edit => { kind => 'write', run => sub { my $result = $workflow->edit_post( { author_user_id => $member->user_id, post_id => $halfway, command_id => $ids->uuid, body_source => 'Bench edit ' . ++${$edit_counter}, viewer => $member } ); $result->{ok} or croak 'benchmark post edit failed'; return $result; } }, rate_limit => { kind => 'write', run => sub { $rate_limit->check($rate_request)->{ok} or croak 'benchmark rate limiter refused'; }, }, new_thread => { kind => 'write', run => sub { my $result = $workflow->create_thread( { author_user_id => $member->user_id, body_source => 'An opening post from the bench.', category_id => $category, command_id => $ids->uuid, title => 'A thread from the bench.', viewer => $member } ); croak "new thread: $result->{status}" if !$result->{ok}; } }, reply => { kind => 'write', run => sub { my $result = $workflow->create_reply( { author_user_id => $member->user_id, body_source => 'A reply from the bench.', command_id => $ids->uuid, thread_id => $thread, viewer => $member, } ); croak "reply: $result->{status}" if !$result->{ok}; }, }, }; } sub _with_path ( $operations, $path ) { if ( $path eq 'dbic' ) { for my $operation ( values %{$operations} ) { my $run = $operation->{run}; $operation->{run} = sub { ## no critic (TestingAndDebugging::ProhibitNoWarnings) -- local replacement makes all nested readers use the legacy benchmark path no warnings 'redefine'; local *GPForum::Infrastructure::SqlFunction::for_schema = sub { return undef; }; return $run->(); }; } } return $operations; } # The statements and round trips a handle sends, through DBI's callbacks. sub _counted ($dbh) { my %count; $dbh->{Callbacks} = { ChildCallbacks => { execute => sub { $count{statements}++; return; }, }, do => sub { $count{statements}++; return; }, map { ( $_ => sub { $count{transaction}++; return; } ) } @ROUND_TRIPS, }; return \%count; } sub _iterations ( $operation, $option ) { return $operation->{kind} eq 'write' ? $option->{'write-iterations'} : $option->{'read-iterations'}; } sub _timed ( $operation, $counts, $option ) { for ( 1 .. $option->{warmup} ) { $operation->{run}->(); } my $iterations = _iterations( $operation, $option ); %{$counts} = (); my @runs; my $processor = clock(); for ( 1 .. $iterations ) { my $started = time; $operation->{run}->(); push @runs, time - $started; } my $processed = ( clock() - $processor ) / $CLOCKS_PER_SECOND; my $statements = ( $counts->{statements} // 0 ) / $iterations; return { cpu_ms => _ms( $processed / $iterations ), median_ms => _ms( _median(@runs) ), p95_ms => _ms( _percentile( \@runs, $PERCENTILE ) ), round_trips => $statements + ( $counts->{transaction} // 0 ) / $iterations, statements => $statements, }; } # Exclusive time charged to DBIx::Class, to DBI and to the rest, per call, # with every method of both wrapped while the operation runs. sub _profiled ( $operation, $option ) { my $profile = GPForum::Benchmark::BucketProfile->new->wrap; my $iterations = _iterations( $operation, $option ); for ( 1 .. $option->{warmup} ) { $operation->{run}->(); } my %total; for ( 1 .. $iterations ) { my $spent = $profile->measure( $operation->{run} ); for my $bucket ( keys %{$spent} ) { $total{$bucket} += $spent->{$bucket}; } } $profile->unwrap; return { map { ( "${_}_ms" => _ms( $total{$_} / $iterations ) ) } keys %total }; } sub _median (@values) { return _percentile( \@values, 1 / 2 ); } sub _percentile ( $values, $share ) { my @sorted = sort { $a <=> $b } @{$values}; return 0 if !@sorted; return $sorted[ int( $share * $#sorted + 1 / 2 ) ]; } sub _ms ($seconds) { return 0 + sprintf '%.3f', $seconds * $MILLISECONDS; } sub _text ($report) { my $text = sprintf 'SELECT 1: %.3f ms at the database, %.3f ms through' . " the proxy (%.2f ms each way)\n\n", @{ $report->{round_trip_ms} }{qw(direct proxy)}, $report->{delay_ms}; $text .= sprintf "%-22s %-9s %8s %8s %8s %8s %8s %8s %8s %6s %6s\n", qw(operation path cpu dbic dbi med p95 med_px p95_px stmts rtrips); for my $op ( sort keys %{ $report->{operations} } ) { for my $path (@PATHS) { my $row = $report->{operations}{$op}{$path}; $text .= sprintf "%-22s %-9s %8.3f %8.3f %8.3f %8.3f %8.3f %8.3f %8.3f %6.1f %6.1f\n", $op, $path, $row->{direct}{cpu_ms}, $row->{profile}{dbic_ms} // 0, $row->{profile}{dbi_ms} // 0, @{ $row->{direct} }{qw(median_ms p95_ms)}, @{ $row->{proxy} }{qw(median_ms p95_ms)}, @{ $row->{direct} }{qw(statements round_trips)}; } } return $text; } 1;