Adding a copy of memcached to the tree.
[awesomized/libmemcached] / memcached / scripts / damemtop
diff --git a/memcached/scripts/damemtop b/memcached/scripts/damemtop
new file mode 100755 (executable)
index 0000000..a5e2bcf
--- /dev/null
@@ -0,0 +1,571 @@
+#!/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;
+}