1#!/usr/bin/perl
2#  dormando's awesome memcached top utility!
3#
4#  Copyright 2009 Dormando ([email protected]).  All rights reserved.
5#
6#  Use and distribution licensed under the BSD license.  See
7#  the COPYING file for full text.
8
9use strict;
10use warnings FATAL => 'all';
11
12use AnyEvent;
13use AnyEvent::Socket;
14use AnyEvent::Handle;
15use Getopt::Long;
16use YAML qw/Dump Load LoadFile/;
17use Term::ReadKey qw/ReadMode ReadKey GetTerminalSize/;
18
19our $VERSION = '0.1';
20
21my $CLEAR     = `clear`;
22my @TERM_SIZE = ();
23$|++;
24
25my %opts = ();
26GetOptions(\%opts, 'help|h', 'config=s');
27
28if ($opts{help}) {
29    show_help(); exit;
30}
31
32$SIG{INT} = sub {
33    ReadMode('normal');
34    print "\n";
35    exit;
36};
37
38# TODO: make this load from central location, and merge in homedir changes.
39# then merge Getopt::Long stuff on top of that
40# TODO: Set a bunch of defaults and merge in.
41my $CONF = load_config();
42my %CONS    = ();
43my $LAST_RUN = time; # time after the last loop cycle.
44my $TIME_SINCE_LAST_RUN = time; # time since last loop cycle.
45my $loop_timer;
46my $main_cond;
47my $prev_stats_results;
48
49my %display_modes = (
50    't' => \&display_top_mode,
51    '?' => \&display_help_mode,
52    'h' => \&display_help_mode,
53);
54
55my %column_compute = (
56    'hostname' => { stats => [], code => \&compute_hostname},
57    'hit_rate' => { stats => ['get_hits', 'get_misses'],
58                    code  => \&compute_hit_rate },
59    'fill_rate' => { stats => ['bytes', 'limit_maxbytes'],
60                    code => \&compute_fill_rate },
61);
62
63my %column_format = (
64    'hit_rate' => \&format_percent,
65    'fill_rate' => \&format_percent,
66);
67
68# This can collapse into %column_compute
69my %column_format_totals = (
70    'hit_rate' => 0,
71    'fill_rate' => 0,
72);
73
74ReadMode('cbreak');
75my $LAST_KEY = '';
76my $read_keys = AnyEvent->io (
77    fh => \*STDIN, poll => 'r',
78    cb => sub {
79        $LAST_KEY = ReadKey(-1);
80        # If there is a running timer, cancel it.
81        # Don't want to interrupt a main loop run.
82        # fire_main_loop()'s iteration will pick up the keypress.
83        if ($loop_timer) {
84            $loop_timer = undef;
85            $main_cond->send;
86        }
87    }
88);
89
90# start main loop
91fire_main_loop();
92
93### AnyEvent related code.
94
95sub fire_main_loop {
96    for (;;) {
97        $loop_timer = undef;
98        $main_cond = AnyEvent->condvar;
99        my $time_taken = main_loop();
100        my $delay = $CONF->{delay} - $time_taken;
101        $delay = 0 if $delay < 0;
102        $loop_timer = AnyEvent->timer(
103            after => $delay,
104            cb    => $main_cond,
105        );
106        $main_cond->recv;
107    }
108}
109
110sub main_loop {
111    my $start = AnyEvent->now; # use ->time to find the end.
112    maintain_connections();
113
114    my $cv = AnyEvent->condvar;
115
116    # FIXME: Need to dump early if there're no connected conns
117    # FIXME: Make this only fetch stats from cons we care to visualize?
118    # maybe keep everything anyway to maintain averages?
119    my %stats_results = ();
120    while (my ($hostname, $con) = each %CONS) {
121        $cv->begin;
122        call_stats($con, ['', 'items', 'slabs'], sub {
123            $stats_results{$hostname} = shift;
124            $cv->end;
125        });
126    }
127    $cv->recv;
128
129    # Short circuit since we don't have anything to compare to.
130    unless ($prev_stats_results) {
131        $prev_stats_results = \%stats_results;
132        return $CONF->{delay};
133    }
134
135    # Semi-exact global time diff for stats that want to average
136    # themselves per-second.
137    my $this_run = AnyEvent->time;
138    $TIME_SINCE_LAST_RUN = $this_run - $LAST_RUN;
139    $LAST_RUN = $this_run;
140
141    # Done all our fetches. Drive the display.
142    display_run($prev_stats_results, \%stats_results);
143    $prev_stats_results = \%stats_results;
144
145    my $end  = AnyEvent->time;
146    my $diff = $LAST_RUN - $start;
147    print "loop took: $diff";
148    return $diff;
149}
150
151sub maintain_connections {
152    my $cv    = AnyEvent->condvar;
153
154    $cv->begin (sub { shift->send });
155    for my $host (@{$CONF->{servers}}) {
156        next if $CONS{$host};
157        $cv->begin;
158        $CONS{$host} = connect_memcached($host, sub {
159            if ($_[0] eq 'err') {
160                print "Failed connecting to $host: ", $_[1], "\n";
161                delete $CONS{$host};
162            }
163            $cv->end;
164        });
165    }
166    $cv->end;
167
168    $cv->recv;
169}
170
171sub connect_memcached {
172    my ($fullhost, $cb)   = @_;
173    my ($host, $port) = split /:/, $fullhost;
174
175    my $con; $con = AnyEvent::Handle->new (
176        connect => [$host => $port],
177        on_connect => sub {
178            $cb->('con');
179        },
180        on_connect_error => sub {
181            $cb->('err', $!);
182            $con->destroy;
183        },
184        on_eof   => sub {
185            $cb->('err', $!);
186            $con->destroy;
187        },
188    );
189    return $con;
190}
191
192# Function's getting a little weird since I started optimizing it.
193# As of my first set of production tests, this routine is where we spend
194# almost all of our processing time.
195sub call_stats {
196    my ($con, $cmds, $cb) = @_;
197
198    my $stats = {};
199    my $num_types = @$cmds;
200
201    my $reader; $reader = sub {
202        my ($con, $results) = @_;
203        {
204            my %temp = ();
205            for my $line (split(/\n/, $results)) {
206                my ($k, $v) = (split(/\s+/, $line))[1,2];
207                $temp{$k} = $v;
208            }
209            $stats->{$cmds->[0]} = \%temp;
210        }
211        shift @$cmds;
212        unless (@$cmds) {
213            # Out of commands to process, return goodies.
214            $cb->($stats);
215            return;
216        }
217    };
218
219    for my $cmd (@$cmds) {
220        $con->push_write('stats ' . $cmd . "\n");
221        $stats->{$cmd} = {};
222        $con->push_read(line => "END\r\n", $reader);
223    }
224}
225
226### Compute routines
227
228sub compute_hostname {
229    return $_[0];
230}
231
232sub compute_hit_rate {
233    my $s = $_[1];
234    my $total = $s->{get_hits} + $s->{get_misses};
235    return 'NA' unless $total;
236    return $s->{get_hits} / $total;
237}
238
239sub compute_fill_rate {
240    my $s = $_[1];
241    return $s->{bytes} / $s->{limit_maxbytes};
242}
243
244sub format_column {
245    my ($col, $val) = @_;
246    my $res;
247    $col =~ s/^all_//;
248    if ($column_format{$col}) {
249        if (ref($column_format{$col}) eq 'CODE') {
250            return $column_format{$col}->($val);
251        } else {
252            return $val .= $column_format{$col};
253        }
254    } else {
255        return format_commas($val);
256    }
257}
258
259sub column_can_total {
260    my $col = shift;
261    $col =~ s/^all_//;
262    return 1 unless exists $column_format_totals{$col};
263    return $column_format_totals{$col};
264}
265
266### Display routines
267
268# If there isn't a specific column type computer, see if we just want to
269# look at the specific stat and return it.
270# If column is a generic type and of 'all_cmd_get' format, return the more
271# complete stat instead of the diffed stat.
272sub compute_column {
273    my ($col, $host, $prev_stats, $curr_stats) = @_;
274    my $diff_stats = 1;
275    $diff_stats    = 0 if ($col =~ s/^all_//);
276
277    # Really should decide on whether or not to flatten the hash :/
278    my $find_stat = sub {
279        for my $type (keys %{$_[0]}) {
280            return $_[0]->{$type}->{$_[1]} if exists $_[0]->{$type}->{$_[1]};
281        }
282    };
283
284    my $diff_stat = sub {
285        my $stat = shift;
286        return 'NA' unless defined $find_stat->($curr_stats, $stat);
287        if ($diff_stats) {
288            my $diff = eval {
289                return ($find_stat->($curr_stats, $stat)
290                       - $find_stat->($prev_stats, $stat))
291                       / $TIME_SINCE_LAST_RUN;
292            };
293            return 'NA' if ($@);
294            return $diff;
295        } else {
296            return $find_stat->($curr_stats, $stat);
297        }
298    };
299
300    if (my $comp = $column_compute{$col}) {
301        my %s = ();
302        for my $stat (@{$comp->{stats}}) {
303            $s{$stat} = $diff_stat->($stat);
304        }
305        return $comp->{code}->($host, \%s);
306    } else {
307        return $diff_stat->($col);
308    }
309    return 'NA';
310}
311
312# We have a bunch of stats from a bunch of connections.
313# At this point we run a particular display mode, capture the lines, then
314# truncate and display them.
315sub display_run {
316    my $prev_stats = shift;
317    my $curr_stats = shift;
318    @TERM_SIZE = GetTerminalSize;
319    die "cannot detect terminal size" unless $TERM_SIZE[0] && $TERM_SIZE[1];
320
321    if ($LAST_KEY eq 'q') {
322        print "\n";
323        ReadMode('normal'); exit;
324    }
325
326    if ($LAST_KEY ne $CONF->{mode} && exists $display_modes{$LAST_KEY}) {
327        $CONF->{prev_mode} = $CONF->{mode};
328        $CONF->{mode} = $LAST_KEY;
329    } elsif ($CONF->{mode} eq 'h' || $CONF->{mode} eq '?') {
330        # Bust out of help mode on any key.
331        $CONF->{mode} = $CONF->{prev_mode};
332    }
333    my $lines = $display_modes{$CONF->{mode}}->($prev_stats, $curr_stats);
334    display_lines($lines) if $lines;
335}
336
337# Default "top" mode.
338# create a set of computed columns as requested by the config.
339# this has gotten a little out of hand... needs more cleanup/abstraction.
340sub display_top_mode {
341    my $prev_stats = shift;
342    my $curr_stats = shift;
343
344    my @columns = @{$CONF->{top_mode}->{columns}};
345    my @rows    = ();
346    my @tot_row = ();
347
348    # Round one.
349    for my $host (sort keys %{$curr_stats}) {
350        my @row = ();
351        for my $colnum (0 .. @columns-1) {
352            my $col = $columns[$colnum];
353            my $res = compute_column($col, $host, $prev_stats->{$host},
354                      $curr_stats->{$host});
355            $tot_row[$colnum] += $res if is_numeric($res);
356            push @row, $res;
357        }
358        push(@rows, \@row);
359    }
360
361    # Sort rows by sort column (ascending or descending)
362    if (my $sort = $CONF->{top_mode}->{sort_column}) {
363        my $order  = $CONF->{top_mode}->{sort_order} || 'asc';
364        my $colnum = 0;
365        for (0 .. @columns-1) { $colnum = $_ if $columns[$_] eq $sort; }
366        my @newrows;
367        if ($order eq 'asc') {
368            if (is_numeric($rows[0]->[$colnum])) {
369                @newrows = sort { $a->[$colnum] <=> $b->[$colnum] } @rows;
370            } else {
371                @newrows = sort { $a->[$colnum] cmp $b->[$colnum] } @rows;
372            }
373        } else {
374            if (is_numeric($rows[0]->[$colnum])) {
375                @newrows = sort { $b->[$colnum] <=> $a->[$colnum] } @rows;
376            } else {
377                @newrows = sort { $b->[$colnum] cmp $a->[$colnum] } @rows;
378            }
379        }
380        @rows = @newrows;
381    }
382
383    # Format each column after the sort...
384    {
385        my @newrows = ();
386        for my $row (@rows) {
387            my @newrow = ();
388            for my $colnum (0 .. @columns-1) {
389                push @newrow, is_numeric($row->[$colnum]) ?
390                            format_column($columns[$colnum], $row->[$colnum]) :
391                            $row->[$colnum];
392            }
393            push @newrows, \@newrow;
394        }
395        @rows = @newrows;
396    }
397
398    # Create average and total rows.
399    my @avg_row = ();
400    for my $col (0 .. @columns-1) {
401        if (is_numeric($tot_row[$col])) {
402            my $countable_rows = 0;
403            for my $row (@rows) {
404                next unless $row->[$col];
405                $countable_rows++ unless $row->[$col] eq 'NA';
406            }
407            $countable_rows = 1 unless $countable_rows;
408            push @avg_row, format_column($columns[$col],
409                 sprintf('%.2f', $tot_row[$col] / $countable_rows));
410        } else {
411            push @avg_row, 'NA';
412        }
413        $tot_row[$col] = 'NA' unless defined $tot_row[$col];
414        $tot_row[$col] = 'NA' unless (column_can_total($columns[$col]));
415        $tot_row[$col] = format_column($columns[$col], $tot_row[$col])
416                         unless $tot_row[$col] eq 'NA';
417    }
418    unshift @rows, \@avg_row;
419    unshift @rows, ['AVERAGE:'];
420    unshift @rows, \@tot_row;
421    unshift @rows, ['TOTAL:'];
422
423    # Round two. Pass @rows into a function which returns an array with the
424    # desired format spacing for each column.
425    unshift @rows, \@columns;
426    my $spacing = find_optimal_spacing(\@rows);
427
428    my @display_lines = ();
429    for my $row (@rows) {
430        my $line = '';
431        for my $col (0 .. @$row-1) {
432            my $space = $spacing->[$col];
433            $line .= sprintf("%-${space}s ", $row->[$col]);
434        }
435        push @display_lines, $line;
436    }
437
438    return \@display_lines;
439}
440
441sub display_help_mode {
442    my $help = <<"ENDHELP";
443
444dormando's awesome memcached top utility version v$VERSION
445
446This early version requires you to edit the ~/.damemtop/damemtop.yaml
447(or /etc/damemtop.yaml) file in order to change options.
448See --help for more info.
449
450Hit any key to exit help.
451ENDHELP
452    my @lines = split /\n/, $help;
453    display_lines(\@lines);
454    $LAST_KEY = ReadKey(0);
455    return;
456}
457
458# Takes a set of lines, clears screen, dumps header, trims lines, etc
459# MAYBE: mode to wrap lines instead of trim them?
460sub display_lines {
461    my $lines = shift;
462
463    my $width         = $TERM_SIZE[0];
464    my $height_remain = $TERM_SIZE[1];
465
466    unshift @$lines, display_header($width);
467    clear_screen() unless $CONF->{no_clear};
468
469    while (--$height_remain && @$lines) {
470        # truncate too long lines.
471        my $line = shift @$lines;
472        $line = substr $line, 0, $width-1;
473        print $line, "\n";
474    }
475}
476
477sub display_header {
478    my $topbar = 'damemtop: ' . scalar localtime;
479    if ($CONF->{mode} eq 't' && $CONF->{top_mode}->{sort_column}) {
480        $topbar .= ' [sort: ' . $CONF->{top_mode}->{sort_column} . ']';
481    }
482    $topbar .= ' [delay: ' . $CONF->{delay} . 's]';
483    return $topbar;
484}
485
486### Utilities
487
488# find the optimal format spacing for each column, which is:
489# longest length of item in col + 2 (whitespace).
490sub find_optimal_spacing {
491    my $rows  = shift;
492    my @maxes = ();
493
494    my $num_cols = @{$rows->[0]};
495    for my $row (@$rows) {
496        for my $col (0 .. $num_cols-1) {
497            $maxes[$col] = 0 unless $maxes[$col];
498            next unless $row->[$col];
499            $maxes[$col] = length($row->[$col])
500                if length($row->[$col]) > $maxes[$col];
501        }
502    }
503    for my $col (0 .. $num_cols) {
504        $maxes[$col] += 1;
505    }
506
507    return \@maxes;
508}
509
510# doesn't try too hard to identify numbers...
511sub is_numeric {
512    return 0 unless $_[0];
513    return 1 if $_[0] =~ m/^\d+(\.\d*)?(\w+)?$/;
514    return 0;
515}
516
517sub format_percent {
518    return sprintf("%.2f%%", $_[0] * 100);
519}
520
521sub format_commas {
522    my $num = shift;
523    $num = int($num);
524    $num =~ s/(^[-+]?\d+?(?=(?>(?:\d{3})+)(?!\d))|\G\d{3}(?=\d))/$1,/g;
525    return $num;
526}
527
528# Can tick counters/etc here as well.
529sub clear_screen {
530    print $CLEAR;
531}
532
533# tries minimally to find a localized config file.
534# TODO: Handle the YAML error and make it prettier.
535sub load_config {
536    my $config = $opts{config} if $opts{config};
537    my $homedir = "$ENV{HOME}/.damemtop/damemtop.yaml";
538    if (-e $homedir) {
539        $config = $homedir;
540    } else {
541        $config = '/etc/damemtop.yaml';
542    }
543    return LoadFile($config);
544}
545
546sub show_help {
547    print <<"ENDHELP";
548dormando's awesome memcached top utility version v$VERSION
549
550This program is copyright (c) 2009 Dormando.
551Use and distribution licensed under the BSD license.  See
552the COPYING file for full text.
553
554contact: dormando\@rydia.net or memcached\@googlegroups.com.
555
556This early version requires you to edit the ~/.damemtop/damemtop.yaml
557(or /etc/damemtop.yaml) file in order to change options.
558
559You may display any column that is in the output of
560'stats', 'stats items', or 'stats slabs' from memcached's ASCII protocol.
561Start a column with 'all_' (ie; 'all_get_hits') to display the current stat,
562otherwise the stat is displayed as an average per second.
563
564Specify a "sort_column" under "top_mode" to sort the output by any column.
565
566Some special "computed" columns exist:
567hit_rate (get/miss hit ratio)
568fill_rate (% bytes used out of the maximum memory limit)
569ENDHELP
570    exit;
571}
572