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