Skip to content
Fetching contributors…
Cannot retrieve contributors at this time
executable file 572 lines (491 sloc) 15.5 KB
#!/usr/bin/perl
# dormando's awesome memcached top utility!
#
# Copyright 2009 Dormando (dormando@rydia.net). All rights reserved.
#
# Use and distribution licensed under the BSD license. See
# the COPYING file for full text.
use strict;
use warnings FATAL => 'all';
use AnyEvent;
use AnyEvent::Socket;
use AnyEvent::Handle;
use Getopt::Long;
use YAML qw/Dump Load LoadFile/;
use Term::ReadKey qw/ReadMode ReadKey GetTerminalSize/;
our $VERSION = '0.1';
my $CLEAR = `clear`;
my @TERM_SIZE = ();
$|++;
my %opts = ();
GetOptions(\%opts, 'help|h', 'config=s');
if ($opts{help}) {
show_help(); exit;
}
$SIG{INT} = sub {
ReadMode('normal');
print "\n";
exit;
};
# TODO: make this load from central location, and merge in homedir changes.
# then merge Getopt::Long stuff on top of that
# TODO: Set a bunch of defaults and merge in.
my $CONF = load_config();
my %CONS = ();
my $LAST_RUN = time; # time after the last loop cycle.
my $TIME_SINCE_LAST_RUN = time; # time since last loop cycle.
my $loop_timer;
my $main_cond;
my $prev_stats_results;
my %display_modes = (
't' => \&display_top_mode,
'?' => \&display_help_mode,
'h' => \&display_help_mode,
);
my %column_compute = (
'hostname' => { stats => [], code => \&compute_hostname},
'hit_rate' => { stats => ['get_hits', 'get_misses'],
code => \&compute_hit_rate },
'fill_rate' => { stats => ['bytes', 'limit_maxbytes'],
code => \&compute_fill_rate },
);
my %column_format = (
'hit_rate' => \&format_percent,
'fill_rate' => \&format_percent,
);
# This can collapse into %column_compute
my %column_format_totals = (
'hit_rate' => 0,
'fill_rate' => 0,
);
ReadMode('cbreak');
my $LAST_KEY = '';
my $read_keys = AnyEvent->io (
fh => \*STDIN, poll => 'r',
cb => sub {
$LAST_KEY = ReadKey(-1);
# If there is a running timer, cancel it.
# Don't want to interrupt a main loop run.
# fire_main_loop()'s iteration will pick up the keypress.
if ($loop_timer) {
$loop_timer = undef;
$main_cond->send;
}
}
);
# start main loop
fire_main_loop();
### AnyEvent related code.
sub fire_main_loop {
for (;;) {
$loop_timer = undef;
$main_cond = AnyEvent->condvar;
my $time_taken = main_loop();
my $delay = $CONF->{delay} - $time_taken;
$delay = 0 if $delay < 0;
$loop_timer = AnyEvent->timer(
after => $delay,
cb => $main_cond,
);
$main_cond->recv;
}
}
sub main_loop {
my $start = AnyEvent->now; # use ->time to find the end.
maintain_connections();
my $cv = AnyEvent->condvar;
# FIXME: Need to dump early if there're no connected conns
# FIXME: Make this only fetch stats from cons we care to visualize?
# maybe keep everything anyway to maintain averages?
my %stats_results = ();
while (my ($hostname, $con) = each %CONS) {
$cv->begin;
call_stats($con, ['', 'items', 'slabs'], sub {
$stats_results{$hostname} = shift;
$cv->end;
});
}
$cv->recv;
# Short circuit since we don't have anything to compare to.
unless ($prev_stats_results) {
$prev_stats_results = \%stats_results;
return $CONF->{delay};
}
# Semi-exact global time diff for stats that want to average
# themselves per-second.
my $this_run = AnyEvent->time;
$TIME_SINCE_LAST_RUN = $this_run - $LAST_RUN;
$LAST_RUN = $this_run;
# Done all our fetches. Drive the display.
display_run($prev_stats_results, \%stats_results);
$prev_stats_results = \%stats_results;
my $end = AnyEvent->time;
my $diff = $LAST_RUN - $start;
print "loop took: $diff";
return $diff;
}
sub maintain_connections {
my $cv = AnyEvent->condvar;
$cv->begin (sub { shift->send });
for my $host (@{$CONF->{servers}}) {
next if $CONS{$host};
$cv->begin;
$CONS{$host} = connect_memcached($host, sub {
if ($_[0] eq 'err') {
print "Failed connecting to $host: ", $_[1], "\n";
delete $CONS{$host};
}
$cv->end;
});
}
$cv->end;
$cv->recv;
}
sub connect_memcached {
my ($fullhost, $cb) = @_;
my ($host, $port) = split /:/, $fullhost;
my $con; $con = AnyEvent::Handle->new (
connect => [$host => $port],
on_connect => sub {
$cb->('con');
},
on_connect_error => sub {
$cb->('err', $!);
$con->destroy;
},
on_eof => sub {
$cb->('err', $!);
$con->destroy;
},
);
return $con;
}
# Function's getting a little weird since I started optimizing it.
# As of my first set of production tests, this routine is where we spend
# almost all of our processing time.
sub call_stats {
my ($con, $cmds, $cb) = @_;
my $stats = {};
my $num_types = @$cmds;
my $reader; $reader = sub {
my ($con, $results) = @_;
{
my %temp = ();
for my $line (split(/\n/, $results)) {
my ($k, $v) = (split(/\s+/, $line))[1,2];
$temp{$k} = $v;
}
$stats->{$cmds->[0]} = \%temp;
}
shift @$cmds;
unless (@$cmds) {
# Out of commands to process, return goodies.
$cb->($stats);
return;
}
};
for my $cmd (@$cmds) {
$con->push_write('stats ' . $cmd . "\n");
$stats->{$cmd} = {};
$con->push_read(line => "END\r\n", $reader);
}
}
### Compute routines
sub compute_hostname {
return $_[0];
}
sub compute_hit_rate {
my $s = $_[1];
my $total = $s->{get_hits} + $s->{get_misses};
return 'NA' unless $total;
return $s->{get_hits} / $total;
}
sub compute_fill_rate {
my $s = $_[1];
return $s->{bytes} / $s->{limit_maxbytes};
}
sub format_column {
my ($col, $val) = @_;
my $res;
$col =~ s/^all_//;
if ($column_format{$col}) {
if (ref($column_format{$col}) eq 'CODE') {
return $column_format{$col}->($val);
} else {
return $val .= $column_format{$col};
}
} else {
return format_commas($val);
}
}
sub column_can_total {
my $col = shift;
$col =~ s/^all_//;
return 1 unless exists $column_format_totals{$col};
return $column_format_totals{$col};
}
### Display routines
# If there isn't a specific column type computer, see if we just want to
# look at the specific stat and return it.
# If column is a generic type and of 'all_cmd_get' format, return the more
# complete stat instead of the diffed stat.
sub compute_column {
my ($col, $host, $prev_stats, $curr_stats) = @_;
my $diff_stats = 1;
$diff_stats = 0 if ($col =~ s/^all_//);
# Really should decide on whether or not to flatten the hash :/
my $find_stat = sub {
for my $type (keys %{$_[0]}) {
return $_[0]->{$type}->{$_[1]} if exists $_[0]->{$type}->{$_[1]};
}
};
my $diff_stat = sub {
my $stat = shift;
return 'NA' unless defined $find_stat->($curr_stats, $stat);
if ($diff_stats) {
my $diff = eval {
return ($find_stat->($curr_stats, $stat)
- $find_stat->($prev_stats, $stat))
/ $TIME_SINCE_LAST_RUN;
};
return 'NA' if ($@);
return $diff;
} else {
return $find_stat->($curr_stats, $stat);
}
};
if (my $comp = $column_compute{$col}) {
my %s = ();
for my $stat (@{$comp->{stats}}) {
$s{$stat} = $diff_stat->($stat);
}
return $comp->{code}->($host, \%s);
} else {
return $diff_stat->($col);
}
return 'NA';
}
# We have a bunch of stats from a bunch of connections.
# At this point we run a particular display mode, capture the lines, then
# truncate and display them.
sub display_run {
my $prev_stats = shift;
my $curr_stats = shift;
@TERM_SIZE = GetTerminalSize;
die "cannot detect terminal size" unless $TERM_SIZE[0] && $TERM_SIZE[1];
if ($LAST_KEY eq 'q') {
print "\n";
ReadMode('normal'); exit;
}
if ($LAST_KEY ne $CONF->{mode} && exists $display_modes{$LAST_KEY}) {
$CONF->{prev_mode} = $CONF->{mode};
$CONF->{mode} = $LAST_KEY;
} elsif ($CONF->{mode} eq 'h' || $CONF->{mode} eq '?') {
# Bust out of help mode on any key.
$CONF->{mode} = $CONF->{prev_mode};
}
my $lines = $display_modes{$CONF->{mode}}->($prev_stats, $curr_stats);
display_lines($lines) if $lines;
}
# Default "top" mode.
# create a set of computed columns as requested by the config.
# this has gotten a little out of hand... needs more cleanup/abstraction.
sub display_top_mode {
my $prev_stats = shift;
my $curr_stats = shift;
my @columns = @{$CONF->{top_mode}->{columns}};
my @rows = ();
my @tot_row = ();
# Round one.
for my $host (sort keys %{$curr_stats}) {
my @row = ();
for my $colnum (0 .. @columns-1) {
my $col = $columns[$colnum];
my $res = compute_column($col, $host, $prev_stats->{$host},
$curr_stats->{$host});
$tot_row[$colnum] += $res if is_numeric($res);
push @row, $res;
}
push(@rows, \@row);
}
# Sort rows by sort column (ascending or descending)
if (my $sort = $CONF->{top_mode}->{sort_column}) {
my $order = $CONF->{top_mode}->{sort_order} || 'asc';
my $colnum = 0;
for (0 .. @columns-1) { $colnum = $_ if $columns[$_] eq $sort; }
my @newrows;
if ($order eq 'asc') {
if (is_numeric($rows[0]->[$colnum])) {
@newrows = sort { $a->[$colnum] <=> $b->[$colnum] } @rows;
} else {
@newrows = sort { $a->[$colnum] cmp $b->[$colnum] } @rows;
}
} else {
if (is_numeric($rows[0]->[$colnum])) {
@newrows = sort { $b->[$colnum] <=> $a->[$colnum] } @rows;
} else {
@newrows = sort { $b->[$colnum] cmp $a->[$colnum] } @rows;
}
}
@rows = @newrows;
}
# Format each column after the sort...
{
my @newrows = ();
for my $row (@rows) {
my @newrow = ();
for my $colnum (0 .. @columns-1) {
push @newrow, is_numeric($row->[$colnum]) ?
format_column($columns[$colnum], $row->[$colnum]) :
$row->[$colnum];
}
push @newrows, \@newrow;
}
@rows = @newrows;
}
# Create average and total rows.
my @avg_row = ();
for my $col (0 .. @columns-1) {
if (is_numeric($tot_row[$col])) {
my $countable_rows = 0;
for my $row (@rows) {
next unless $row->[$col];
$countable_rows++ unless $row->[$col] eq 'NA';
}
$countable_rows = 1 unless $countable_rows;
push @avg_row, format_column($columns[$col],
sprintf('%.2f', $tot_row[$col] / $countable_rows));
} else {
push @avg_row, 'NA';
}
$tot_row[$col] = 'NA' unless defined $tot_row[$col];
$tot_row[$col] = 'NA' unless (column_can_total($columns[$col]));
$tot_row[$col] = format_column($columns[$col], $tot_row[$col])
unless $tot_row[$col] eq 'NA';
}
unshift @rows, \@avg_row;
unshift @rows, ['AVERAGE:'];
unshift @rows, \@tot_row;
unshift @rows, ['TOTAL:'];
# Round two. Pass @rows into a function which returns an array with the
# desired format spacing for each column.
unshift @rows, \@columns;
my $spacing = find_optimal_spacing(\@rows);
my @display_lines = ();
for my $row (@rows) {
my $line = '';
for my $col (0 .. @$row-1) {
my $space = $spacing->[$col];
$line .= sprintf("%-${space}s ", $row->[$col]);
}
push @display_lines, $line;
}
return \@display_lines;
}
sub display_help_mode {
my $help = <<"ENDHELP";
dormando's awesome memcached top utility version v$VERSION
This early version requires you to edit the ~/.damemtop/damemtop.yaml
(or /etc/damemtop.yaml) file in order to change options.
See --help for more info.
Hit any key to exit help.
ENDHELP
my @lines = split /\n/, $help;
display_lines(\@lines);
$LAST_KEY = ReadKey(0);
return;
}
# Takes a set of lines, clears screen, dumps header, trims lines, etc
# MAYBE: mode to wrap lines instead of trim them?
sub display_lines {
my $lines = shift;
my $width = $TERM_SIZE[0];
my $height_remain = $TERM_SIZE[1];
unshift @$lines, display_header($width);
clear_screen() unless $CONF->{no_clear};
while (--$height_remain && @$lines) {
# truncate too long lines.
my $line = shift @$lines;
$line = substr $line, 0, $width-1;
print $line, "\n";
}
}
sub display_header {
my $topbar = 'damemtop: ' . scalar localtime;
if ($CONF->{mode} eq 't' && $CONF->{top_mode}->{sort_column}) {
$topbar .= ' [sort: ' . $CONF->{top_mode}->{sort_column} . ']';
}
$topbar .= ' [delay: ' . $CONF->{delay} . 's]';
return $topbar;
}
### Utilities
# find the optimal format spacing for each column, which is:
# longest length of item in col + 2 (whitespace).
sub find_optimal_spacing {
my $rows = shift;
my @maxes = ();
my $num_cols = @{$rows->[0]};
for my $row (@$rows) {
for my $col (0 .. $num_cols-1) {
$maxes[$col] = 0 unless $maxes[$col];
next unless $row->[$col];
$maxes[$col] = length($row->[$col])
if length($row->[$col]) > $maxes[$col];
}
}
for my $col (0 .. $num_cols) {
$maxes[$col] += 1;
}
return \@maxes;
}
# doesn't try too hard to identify numbers...
sub is_numeric {
return 0 unless $_[0];
return 1 if $_[0] =~ m/^\d+(\.\d*)?(\w+)?$/;
return 0;
}
sub format_percent {
return sprintf("%.2f%%", $_[0] * 100);
}
sub format_commas {
my $num = shift;
$num = int($num);
$num =~ s/(^[-+]?\d+?(?=(?>(?:\d{3})+)(?!\d))|\G\d{3}(?=\d))/$1,/g;
return $num;
}
# Can tick counters/etc here as well.
sub clear_screen {
print $CLEAR;
}
# tries minimally to find a localized config file.
# TODO: Handle the YAML error and make it prettier.
sub load_config {
my $config = $opts{config} if $opts{config};
my $homedir = "$ENV{HOME}/.damemtop/damemtop.yaml";
if (-e $homedir) {
$config = $homedir;
} else {
$config = '/etc/damemtop.yaml';
}
return LoadFile($config);
}
sub show_help {
print <<"ENDHELP";
dormando's awesome memcached top utility version v$VERSION
This program is copyright (c) 2009 Dormando.
Use and distribution licensed under the BSD license. See
the COPYING file for full text.
contact: dormando\@rydia.net or memcached\@googlegroups.com.
This early version requires you to edit the ~/.damemtop/damemtop.yaml
(or /etc/damemtop.yaml) file in order to change options.
You may display any column that is in the output of
'stats', 'stats items', or 'stats slabs' from memcached's ASCII protocol.
Start a column with 'all_' (ie; 'all_get_hits') to display the current stat,
otherwise the stat is displayed as an average per second.
Specify a "sort_column" under "top_mode" to sort the output by any column.
Some special "computed" columns exist:
hit_rate (get/miss hit ratio)
fill_rate (% bytes used out of the maximum memory limit)
ENDHELP
exit;
}
Something went wrong with that request. Please try again.