You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@spamassassin.apache.org by jm...@apache.org on 2004/11/21 02:58:07 UTC

svn commit: r106054 - in spamassassin/trunk: . lib/Mail/SpamAssassin spamd

Author: jm
Date: Sat Nov 20 17:58:04 2004
New Revision: 106054

Added:
   spamassassin/trunk/lib/Mail/SpamAssassin/SpamdForkScaling.pm
   spamassassin/trunk/lib/Mail/SpamAssassin/SubProcBackChannel.pm
Modified:
   spamassassin/trunk/MANIFEST
   spamassassin/trunk/spamd/spamd.raw
Log:
bug 3983: Apache preforking algorithm adopted; number of spamd child processes is now scaled, according to demand.

Modified: spamassassin/trunk/MANIFEST
==============================================================================
--- spamassassin/trunk/MANIFEST	(original)
+++ spamassassin/trunk/MANIFEST	Sat Nov 20 17:58:04 2004
@@ -52,6 +52,8 @@
 lib/Mail/SpamAssassin/Plugin/URIDNSBL.pm
 lib/Mail/SpamAssassin/PluginHandler.pm
 lib/Mail/SpamAssassin/Reporter.pm
+lib/Mail/SpamAssassin/SpamdForkScaling.pm
+lib/Mail/SpamAssassin/SubProcBackChannel.pm
 lib/Mail/SpamAssassin/SQLBasedAddrList.pm
 lib/Mail/SpamAssassin/TextCat.pm
 lib/Mail/SpamAssassin/Util.pm

Added: spamassassin/trunk/lib/Mail/SpamAssassin/SpamdForkScaling.pm
==============================================================================
--- (empty file)
+++ spamassassin/trunk/lib/Mail/SpamAssassin/SpamdForkScaling.pm	Sat Nov 20 17:58:04 2004
@@ -0,0 +1,383 @@
+# spamd prefork scaling, using an Apache-based algorithm
+#
+# <@LICENSE>
+# Copyright 2004 Apache Software Foundation
+# 
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+# 
+#     http://www.apache.org/licenses/LICENSE-2.0
+# 
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# </...@LICENSE>
+
+package Mail::SpamAssassin::SpamdForkScaling;
+
+*dbg=\&Mail::SpamAssassin::dbg;
+
+use strict;
+use warnings;
+use bytes;
+
+use Mail::SpamAssassin::Util;
+
+use vars qw {
+  @PFSTATE_VARS %EXPORT_TAGS @EXPORT_OK
+};
+
+use base qw( Exporter );
+
+@PFSTATE_VARS = qw(
+  PFSTATE_ERROR PFSTATE_STARTING PFSTATE_IDLE PFSTATE_BUSY PFSTATE_KILLED
+  PFORDER_ACCEPT
+);
+
+%EXPORT_TAGS = (
+  'pfstates' => [ @PFSTATE_VARS ]
+);
+@EXPORT_OK = ( @PFSTATE_VARS );
+
+use constant PFSTATE_ERROR       => -1;
+use constant PFSTATE_STARTING    => 0;
+use constant PFSTATE_IDLE        => 1;
+use constant PFSTATE_BUSY        => 2;
+use constant PFSTATE_KILLED      => 3;
+
+use constant PFORDER_ACCEPT      => 10;
+
+###########################################################################
+
+sub new {
+  my $class = shift;
+  $class = ref($class) || $class;
+
+  my $self = shift;
+  if (!defined $self) { $self = { }; }
+  bless ($self, $class);
+
+  $self->{kids} = { };
+  $self->{overloaded} = 0;
+  $self->{min_children} ||= 1;
+
+  $self;
+}
+
+###########################################################################
+# Parent methods
+
+sub add_child {
+  my ($self, $pid) = @_;
+  $self->set_child_state ($pid, PFSTATE_STARTING);
+}
+
+sub child_exited {
+  my ($self, $pid) = @_;
+  delete $self->{kids}->{$pid};
+}
+
+sub set_child_state {
+  my ($self, $pid, $state) = @_;
+  $self->{kids}->{$pid} = $state;
+  dbg ("prefork: child $pid: entering state $state");
+  $self->compute_lowest_child_pid();
+}
+
+sub compute_lowest_child_pid {
+  my ($self) = @_;
+
+  my @pids = grep { $self->{kids}->{$_} == PFSTATE_IDLE }
+        keys %{$self->{kids}};
+
+  my $l = shift @pids;
+  foreach my $p (@pids) {
+    if ($l > $p) { $l = $p };
+  }
+  $self->{lowest_idle_pid} = $l;
+}
+
+###########################################################################
+
+sub set_server_fh {
+  my ($self, $fh) = @_;
+  $self->{server_fh} = $fh;
+  $self->{server_fileno} = $fh->fileno();
+}
+
+sub main_server_poll {
+  my ($self, $tout) = @_;
+
+  my $rin = ${$self->{backchannel}->{selector}};
+  if ($self->{overloaded}) {
+    # don't select on the server fh -- we already KNOW that's ready,
+    # since we're overloaded
+    vec($rin, $self->{server_fileno}, 1) = 0;
+  }
+
+  my ($rout, $eout);
+  my ($nfound, $timeleft) = select($rout=$rin, undef, $eout=$rin, $tout);
+
+  # any action?
+  return unless ($nfound);
+
+  # were the kids ready, or did we get signal?
+  if (vec ($rout, $self->{server_fileno}, 1)) {
+    # dbg("prefork: server fh ready");
+    # the server socket: new connection from a client
+    if (!$self->order_idle_child_to_accept()) {
+      # dbg("prefork: no idle kids, noting overloaded");
+      # there are no idle kids!  we're overloaded, mark that
+      $self->{overloaded}++;
+    }
+    return;
+  }
+
+  foreach my $fh ($self->{backchannel}->select_vec_to_fh_list($rout))
+  {
+    # otherwise it's a status report from a child.
+    # just read one line.  if there's more lines, we'll get them
+    # when we re-enter the can_read() select call above...
+    if ($self->read_one_line_from_child_socket($fh) == PFSTATE_IDLE)
+    {
+      dbg("prefork: child reports idle");
+      if ($self->{overloaded}) {
+        # if we were overloaded, then now that this kid is idle,
+        # we can use it to handle the waiting connection.  zero
+        # the overloaded flag, anyway; if there's >1 waiting
+        # conn, they'll show up next time we do the select.
+
+        dbg("prefork: overloaded, immediately telling kid to accept");
+        if (!$self->order_idle_child_to_accept()) {
+          # this should not happen
+          warn "prefork: oops! still overloaded?";
+        }
+        dbg("prefork: no longer overloaded");
+        $self->{overloaded} = 0;
+      }
+    }
+  }
+
+  # now that we've ordered some kids to accept any new connections,
+  # increase/decrease the pool as necessary
+  $self->adapt_num_children();
+}
+
+sub read_one_line_from_child_socket {
+  my ($self, $sock) = @_;
+
+  my $line = $sock->getline();
+  if (!defined $line) {
+    dbg ("prefork: child closed connection");
+
+    # stop it being select'd
+    vec(${$self->{backchannel}->{selector}}, $sock->fileno, 1) = 0;
+    $sock->close();
+    return PFSTATE_ERROR;
+  }
+
+  chomp $line;
+  if ($line =~ /^I(\d+)/) {
+    $self->set_child_state ($1, PFSTATE_IDLE);
+    return PFSTATE_IDLE;
+  }
+  elsif ($line =~ /^B(\d+)/) {
+    $self->set_child_state ($1, PFSTATE_BUSY);
+    return PFSTATE_BUSY;
+  }
+  else {
+    die "unknown message from child: '$line'";
+    return PFSTATE_ERROR;
+  }
+}
+
+###########################################################################
+
+# we use the following protocol between the master and child processes to
+# control when they accept/who accepts: server tells a child to accept with a
+# "A\n", child responds with "B$pid\n" when it's busy, and "I$pid\n" once it's
+# idle again.  Very simple, line-based protocol.
+
+sub order_idle_child_to_accept {
+  my ($self) = @_;
+
+  my $kid = $self->{lowest_idle_pid};
+  if (defined $kid) {
+    my $sock = $self->{backchannel}->get_socket_for_child($kid);
+    $sock->syswrite ("A\n");
+    dbg ("prefork: ordered $kid to accept");
+
+    # now wait for it to say it's done that
+    return $self->wait_for_child_to_accept($sock);
+
+  }
+  else {
+    dbg ("prefork: no spare children to accept, waiting for one to complete");
+    return undef;
+  }
+}
+
+sub wait_for_child_to_accept {
+  my ($self, $sock) = @_;
+
+  while (1) {
+    my $state = $self->read_one_line_from_child_socket($sock);
+    if ($state == PFSTATE_BUSY) {
+      return 1;     # 1 == success
+    }
+    if ($state == PFSTATE_ERROR) {
+      return undef;
+    }
+    else {
+      die "prefork: ordered child to accept, but child reported state '$state'";
+    }
+  }
+}
+
+sub child_now_ready_to_accept {
+  my ($self, $kid) = @_;
+  if ($self->{waiting_for_idle_child}) {
+    my $sock = $self->{backchannel}->get_socket_for_child($kid);
+    $sock->syswrite ("A\n");
+    $self->{waiting_for_idle_child} = 0;
+  }
+}
+
+###########################################################################
+# Child methods
+
+sub set_my_pid {
+  my ($self, $pid) = @_;
+  $self->{pid} = $pid;  # save calling $$ all the time
+}
+
+sub update_child_status_idle {
+  my ($self) = @_;
+  $self->report_backchannel_socket("I".$self->{pid}."\n");
+}
+
+sub update_child_status_busy {
+  my ($self) = @_;
+  $self->report_backchannel_socket("B".$self->{pid}."\n");
+}
+
+sub report_backchannel_socket {
+  my ($self, $str) = @_;
+  my $sock = $self->{backchannel}->get_parent_socket();
+  syswrite ($sock, $str)
+        or write "syswrite() to parent failed: $!";
+}
+
+sub wait_for_orders {
+  my ($self) = @_;
+
+  my $sock = $self->{backchannel}->get_parent_socket();
+  while (1) {
+    my $line = $sock->getline();
+    chomp $line if defined $line;
+    if (index ($line, "A") == 0) {  # string starts with "A" = accept
+      return PFORDER_ACCEPT;
+    }
+    else {
+      die "unknown order from parent: '$line'";
+      return undef;
+    }
+  }
+}
+
+###########################################################################
+# Master server code again
+
+# this is pretty much the algorithm from perform_idle_server_maintainance() in
+# Apache's "prefork" MPM.  However: we don't do exponential server spawning,
+# since our servers are a lot more heavyweight than theirs is.
+
+sub adapt_num_children {
+  my ($self) = @_;
+
+  my $kids = $self->{kids};
+  my $statestr = '';
+  my $num_idle = 0;
+  my @pids = sort { $a <=> $b } keys %{$kids};
+  my $num_servers = scalar @pids;
+
+  foreach my $pid (@pids) {
+    my $k = $kids->{$pid};
+    if ($k == PFSTATE_IDLE) {
+      $statestr .= 'I';
+      $num_idle++;
+    }
+    elsif ($k == PFSTATE_BUSY) {
+      $statestr .= 'B';
+    }
+    elsif ($k == PFSTATE_KILLED) {
+      $statestr .= 'K';
+    }
+    elsif ($k == PFSTATE_ERROR) {
+      $statestr .= 'E';
+    }
+    elsif ($k == PFSTATE_STARTING) {
+      $statestr .= 'S';
+    }
+    else {
+      $statestr .= '?';
+    }
+  }
+  dbg ("prefork: child states: ".$statestr);
+
+  # just kill off/add one at a time, to avoid swamping stuff and
+  # reacting too quickly; Apache emulation
+  if ($num_idle < $self->{min_idle}) {
+    if ($num_servers < $self->{max_children}) {
+      $self->need_to_add_server($num_idle);
+    } else {
+      warn "prefork: server reached --max-clients setting, consider raising it\n";
+    }
+  }
+  elsif ($num_idle > $self->{max_idle} && $num_servers > $self->{min_children}) {
+    $self->need_to_del_server($num_idle);
+  }
+}
+
+sub need_to_add_server {
+  my ($self, $num_idle) = @_;
+  my $cur = ${$self->{cur_children_ref}};
+  $cur++;
+  dbg ("prefork: adjust: increasing, not enough idle children ($num_idle < $self->{min_idle})");
+  main::spawn();
+  # servers will be started once main_server_poll() returns
+}
+
+sub need_to_del_server {
+  my ($self, $num_idle) = @_;
+  my $cur = ${$self->{cur_children_ref}};
+  $cur--;
+  my $pid;
+  foreach my $k (keys %{$self->{kids}}) {
+    my $v = $self->{kids}->{$k};
+    if ($v == PFSTATE_IDLE)
+    {
+      # kill the highest; Apache emulation, exploits linux scheduler
+      # behaviour (and is predictable)
+      if (!defined $pid || $k > $pid) {
+        $pid = $k;
+      }
+    }
+  }
+
+  if (!defined $pid) {
+    # this should be impossible. assert it
+    die "oops! no idle kids in need_to_del_server?";
+  }
+
+  kill 'INT' => $pid;
+  $self->set_child_state ($pid, PFSTATE_KILLED);
+  dbg ("prefork: adjust: decreasing, too many idle children ($num_idle > $self->{max_idle}), killed $pid");
+}
+
+1;
+
+__END__

Added: spamassassin/trunk/lib/Mail/SpamAssassin/SubProcBackChannel.pm
==============================================================================
--- (empty file)
+++ spamassassin/trunk/lib/Mail/SpamAssassin/SubProcBackChannel.pm	Sat Nov 20 17:58:04 2004
@@ -0,0 +1,155 @@
+# back-channel for communication between a master and multiple slave processes.
+#
+# <@LICENSE>
+# Copyright 2004 Apache Software Foundation
+# 
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+# 
+#     http://www.apache.org/licenses/LICENSE-2.0
+# 
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# </...@LICENSE>
+
+package Mail::SpamAssassin::SubProcBackChannel;
+
+use strict;
+use warnings;
+use bytes;
+
+use IO::Socket;
+use Mail::SpamAssassin::Util;
+use Mail::SpamAssassin::Constants qw(:sa);
+
+use vars qw {
+};
+
+my @ISA = qw();
+
+=head1 NAME
+
+Mail::SpamAssassin::SubProcBackChannel - back-channel for communication between a master and multiple slave processes
+
+=head1 METHODS
+
+=over 4
+
+=cut
+
+
+###########################################################################
+
+sub new {
+  my $class = shift;
+  $class = ref($class) || $class;
+
+  my $self = shift;
+  if (!defined $self) { $self = { }; }
+  bless ($self, $class);
+
+  $self->{kids} = { };
+  $self->{fileno_to_fh} = { };
+
+  $self;
+}
+
+###########################################################################
+
+sub set_selector {
+  my ($self, $sel) = @_;
+  $self->{selector} = $sel;
+}
+
+sub setup_backchannel_parent_pre_fork {
+  my ($self) = @_;
+
+  my $io = IO::Socket->new();
+  ($self->{latest_kid_fh}, $self->{parent}) =
+            $io->socketpair(AF_UNIX,SOCK_STREAM,PF_UNSPEC)
+            or die "backchannel: socketpair failed: $!";
+}
+
+sub setup_backchannel_parent_post_fork {
+  my ($self, $pid) = @_;
+
+  my $fh = $self->{latest_kid_fh};
+
+  close $self->{parent};    # because it's us!
+
+  # disable caching for parent<->child relations
+  my ($old) = select($fh);
+  $|++;
+  select($old);
+
+  $self->{kids}->{$pid} = $fh;
+  $self->add_to_selector($fh);
+}
+
+sub add_to_selector {
+  my ($self, $fh) = @_;
+  my $fno = fileno($fh);
+  $self->{fileno_to_fh}->{$fno} = $fh;
+  vec (${$self->{selector}}, $fno, 1) = 1;
+}
+
+sub select_vec_to_fh_list {
+  my ($self, $vec) = @_;
+  my $i = -1;
+
+  # grotesque hackery alert! ;)   turn the vec() map of fds into a list of
+  # filehandles.  note that filenos that don't have a filehandle in the
+  # {fileno_to_fh} hash will be ignored; this is by design, so that other fhs
+  # can be selected on using the same vec, and the caller can just check for
+  # those in their own code, before they fall back to using this method.
+
+  return grep {
+        defined
+      } map {
+        $i++;
+        ($_ ? $self->{fileno_to_fh}->{$i} : undef);
+      } split (//, unpack ("b*", $vec));
+}
+
+sub get_socket_for_child {
+  my ($self, $pid) = @_;
+  return $self->{kids}->{$pid};
+}
+
+###########################################################################
+
+sub setup_backchannel_child_post_fork {
+  my ($self) = @_;
+
+  close $self->{latest_kid_fh}; # because it's us!
+
+  my $old = select($self->{parent});
+  $| = 1;   # print to parent by default, turn off buffering
+  select($old);
+}
+
+sub get_parent_socket {
+  my ($self) = @_;
+  return $self->{parent};
+}
+
+############################################################################
+
+1;
+
+__END__
+
+=back
+
+=head1 SEE ALSO
+
+C<Mail::SpamAssassin>
+C<Mail::SpamAssassin::ArchiveIterator>
+C<Mail::SpamAssassin::SpamdPreforkScaling>
+C<spamassassin>
+C<spamd>
+C<mass-check>

Modified: spamassassin/trunk/spamd/spamd.raw
==============================================================================
--- spamassassin/trunk/spamd/spamd.raw	(original)
+++ spamassassin/trunk/spamd/spamd.raw	Sat Nov 20 17:58:04 2004
@@ -39,6 +39,8 @@
 
 use Mail::SpamAssassin;
 use Mail::SpamAssassin::NetSet;
+use Mail::SpamAssassin::SubProcBackChannel;
+use Mail::SpamAssassin::SpamdForkScaling qw(:pfstates);
 
 use Getopt::Long;
 use Pod::Usage;
@@ -82,6 +84,8 @@
   EX_CONFIG      => 78,    # configuration error
 );
 
+*dbg = \&Mail::SpamAssassin::dbg;
+
 
 sub print_version {
   printf("%s version %s\n", "SpamAssassin Server", Mail::SpamAssassin::Version());
@@ -109,8 +113,14 @@
 my %opt = (
   'user-config'   => 1,
   'ident-timeout' => 5.0,
+  # scaling settings; some of these aren't actually settable via cmdline
+  'server-scale-period' => 2,   # how often to scale the # of kids, secs
+  'min-children'  => 1,         # min kids to have running
+  'min-spare'     => 1,         # min kids that must be spare
+  'max-spare'     => 2,         # max kids that should be spare
 );
 
+
 # Untaint all command-line options and ENV vars, since spamd is launched
 # as a daemon from a known-safe environment. Also store away some of the
 # vars we need for a SIGHUP later on.
@@ -153,7 +163,11 @@
   'listen-ip|ip-address|i:s' => \$opt{'listen-ip'},
   'local!'                   => \$opt{'local'},
   'L'                        => \$opt{'local'},
+  'round-robin!'             => \$opt{'round-robin'},
+  'min-children=i'           => \$opt{'min-children'},
   'max-children|m=i'         => \$opt{'max-children'},
+  'min-spare=i'              => \$opt{'min-spare'},
+  'max-spare=i'              => \$opt{'max-spare'},
   'max-conn-per-child=i'     => \$opt{'max-conn-per-child'},
   'nouser-config|x'          => sub { $opt{'user-config'} = 0 },
   'paranoid!'                => \$opt{'paranoid'},
@@ -515,6 +529,22 @@
 $childlimit        ||= 5;
 $clients_per_child ||= 200;
 
+# ensure scaling parameters are logical
+if ($opt{'min-children'} < 1) {
+  $opt{'min-children'} = 1;
+}
+if ($opt{'min-spare'} < 0) {
+  $opt{'min-spare'} = 0;
+}
+if ($opt{'min-spare'} > $childlimit) {
+  $opt{'min-spare'} = $childlimit-1;
+}
+if ($opt{'max-spare'} < $opt{'min-spare'}) {
+  # emulate Apache behaviour:
+  # http://httpd.apache.org/docs-2.0/mod/prefork.html#maxspareservers
+  $opt{'max-spare'} = $opt{'min-spare'}+1;
+}
+
 
 my $dontcopy = 1;
 if ( $opt{'create-prefs'} ) { $dontcopy = 0; }
@@ -571,6 +601,29 @@
   $listeninfo = "port $port/tcp";
 }
 
+my $backchannel = Mail::SpamAssassin::SubProcBackChannel->new();
+my $scaling;
+if (!$opt{'round-robin'})
+{
+  my $max_children = $childlimit;
+
+  # change $childlimit to avoid churn when we startup and create loads
+  # of spare servers; when we're using scaling, it's not as important
+  # as it was with the old algorithm.
+  if ($childlimit > $opt{'max-spare'}) {
+    $childlimit = $opt{'max-spare'};
+  }
+
+  $scaling = Mail::SpamAssassin::SpamdForkScaling->new({
+        backchannel => $backchannel,
+        min_children => $opt{'min-children'},
+        max_children => $max_children,
+        min_idle => $opt{'min-spare'},
+        max_idle => $opt{'max-spare'},
+        cur_children_ref => \$childlimit
+      });
+}
+
 # Be a well-behaved daemon
 my $server;
 if ( $opt{'socketpath'} ) {
@@ -747,6 +800,10 @@
 my $got_sighup;
 setup_parent_sig_handlers();
 
+my $select_mask = '';
+vec($select_mask, $server->fileno, 1) = 1;
+$backchannel->set_selector(\$select_mask);
+
 # log server started, but processes watching the log to wait for connect
 # should wait until they see the pid, after signal handlers are in place
 if ( defined $opt{'debug'} ) {
@@ -781,8 +838,16 @@
   warn "server pid: $$\n";
 }
 
+if ($scaling) {
+  $scaling->set_server_fh($server);
+}
+
 while (1) {
-  sleep;    # wait for a signal (ie: child's death)
+  if (!$scaling) {
+    sleep;    # wait for a signal (ie: child's death)
+  } else {
+    $scaling->main_server_poll($opt{'server-scale-period'});
+  }
 
   if ( defined $got_sighup ) {
     if (defined($opt{'pidfile'})) {
@@ -803,7 +868,7 @@
       . ": $!\n";
   }
 
-  for ( my $i = keys %children ; $i < $childlimit ; $i++ ) {
+  for (my $i = keys %children; $i < $childlimit; $i++) {
     spawn();
   }
 }
@@ -812,6 +877,8 @@
 sub spawn {
   my $pid;
 
+  $backchannel->setup_backchannel_parent_pre_fork();
+
   # block signal for fork
   my $sigset = POSIX::SigSet->new( POSIX::SIGINT() );
   sigprocmask( POSIX::SIG_BLOCK(), $sigset )
@@ -826,6 +893,10 @@
       or die "Can't unblock SIGINT for fork: $!\n";
     $children{$pid} = 1;
     logmsg("server successfully spawned child process, pid $pid");
+    $backchannel->setup_backchannel_parent_post_fork($pid);
+    if ($scaling) {
+      $scaling->add_child($pid);
+    }
     return;
   }
   else {
@@ -864,8 +935,22 @@
     # this will help make it clear via process listing which is child/parent
     $0 = 'spamd child';
 
+    $backchannel->setup_backchannel_child_post_fork();
+    if ($scaling) {     # only do this once, for efficiency; $$ is a syscall
+      $scaling->set_my_pid($$);
+    }
+
     # handle $clients_per_child connections, then die in "old" age...
+    my $orders;
     for ( my $i = 0 ; $i < $clients_per_child ; $i++ ) {
+      if ($scaling) {
+        $scaling->update_child_status_idle();
+        $orders = $scaling->wait_for_orders(); # and sleep...
+
+        if ($orders != PFORDER_ACCEPT) {
+          logmsg ("unknown order: $orders");
+        }
+      }
 
       # use a large eval scope to catch die()s and ensure they
       # don't kill the server.
@@ -934,6 +1019,10 @@
 
   $client->autoflush(1);
 
+  if ($scaling) {
+    $scaling->update_child_status_busy();
+  }
+
   # keep track of start time
   my $start = time;
 
@@ -1503,7 +1592,7 @@
 
 sub handle_user_ldap {
   my $username = shift;
-  Mail::SpamAssassin::dbg("handle_user_ldap($username)");
+  dbg("handle_user_ldap($username)");
   $spamtest->load_scoreonly_ldap($username);
   $spamtest->signal_user_changed(
     {
@@ -1626,6 +1715,7 @@
   $SIG{CHLD} = \&child_handler;
   $SIG{INT}  = \&kill_handler;
   $SIG{TERM} = \&kill_handler;
+  $SIG{PIPE} = 'IGNORE';
 }
 
 # sig handlers: child processes
@@ -1775,6 +1865,10 @@
     # remove them from our child listing
     delete $children{$pid};
 
+    if ($scaling) {
+      $scaling->child_exited($pid);
+    }
+
     unless ($main::INHIBIT_LOGGING_IN_SIGCHLD_HANDLER) {
       logmsg("handled cleanup of child pid $pid");
     }
@@ -1919,8 +2013,12 @@
  -i [ipaddr], --listen-ip=ipaddr    Listen on the IP ipaddr
  -p port, --port                    Listen on specified port
  -m num, --max-children=num         Allow maximum num children
+ --min-children=num                 Allow minimum num children
+ --min-spare=num                    Lower limit for number of spare children
+ --max-spare=num                    Upper limit for number of spare children
  --max-conn-per-child=num	    Maximum connections accepted by child 
                                     before it is respawned
+ --round-robin                      Use traditional prefork algorithm
  -q, --sql-config                   Enable SQL config (only useful with -x)
  -Q, --setuid-with-sql              Enable SQL config (only useful with -x,
                                     enables use of -H)
@@ -2236,11 +2334,45 @@
 Please note that there is a OS specific maximum of connections that can be
 queued (Try C<perl -MSocket -e'print SOMAXCONN'> to find this maximum).
 
+Note that if you run too many servers for the amount of free RAM available, you
+run the danger of hurting performance by causing a high swap load as server
+processes are swapped in and out continually.
+
+=item B<--min-children>=I<number>
+
+The minimum number of children that will be kept running.  The minimum value is
+C<1>, the default value is C<1>.  If you have lots of free RAM, you may want to
+increase this.
+
+=item B<--min-spare>=I<number>
+
+The lower limit for the number of spare children allowed to run.  A
+spare, or idle, child is one that is not handling a scan request.   If
+there are too few spare children available, a new server will be started
+every second or so.  The default value is C<1>.
+
+=item B<--max-spare>=I<number>
+
+The upper limit for the number of spare children allowed to run.  If there
+are too many spare children, one will be killed every second or so until
+the number of idle children is in the desired range.  The default value
+is C<2>.
+
 =item B<--max-conn-per-child>=I<number>
 
 This option specifies the maximum number of connections each child
 should process before dying and letting the master spamd process spawn
 a new child.  The minimum value is C<1>, the default value is C<200>.
+
+=item B<--round-robin>
+
+By default, C<spamd> will attempt to keep a small number of "hot" child
+processes as busy as possible, and keep any others as idle as possible, using
+something similar to the Apache httpd server scaling algorithm.  This is
+accomplished by the master process coordinating the activities of the children.
+This switch will disable this scaling algorithm, and the behaviour seen in
+versions 3.0.0 and 3.0.1 will be used instead, where all processes receive an
+equal load and no scaling takes place.
 
 =item B<-H> I<directory>, B<--helper-home-dir>=I<directory>