Bug 6996 - spamd --round-robin conflicts with multiple listen sockets
Summary: spamd --round-robin conflicts with multiple listen sockets
Status: RESOLVED FIXED
Alias: None
Product: Spamassassin
Classification: Unclassified
Component: spamc/spamd (show other bugs)
Version: SVN Trunk (Latest Devel Version)
Hardware: All All
: P2 blocker
Target Milestone: 3.4.0
Assignee: SpamAssassin Developer Mailing List
URL:
Whiteboard:
Keywords:
Depends on:
Blocks:
 
Reported: 2014-01-30 13:54 UTC by Mark Martinec
Modified: 2014-02-05 19:05 UTC (History)
3 users (show)



Attachment Type Modified Status Actions Submitter/CLA Status
Proposed patch - adds locking across select+accept on multiple sockets patch None Mark Martinec [HasCLA]
patch to fix min-children in spamd patch None RW [NoCLA]

Note You need to log in before you can comment on or make changes to this bug.
Description Mark Martinec 2014-01-30 13:54:25 UTC
Spamd has two modes of managing its child processes:

- when option --round-robin is specified, all free child processes
  call accept() and simultaneously wait for a new task, kernel decides
  which child process gets the job (its accept() call returns).
  (so the --round-robin is not exactly a 'round robin', but rather
  a 'catch as catch can')

- when --round-robin is *not* specified, task scheduling is a job
  of the master process, using a SpamdForkScaling module. The master
  waits on a select() for a new request, then delegates one of the
  child processes (through backchannel pipe) to go ahead and
  call accept() and take the request.

When multiple listen sockets are specified (like inet and unix
and ssl sockets with SpamAssassin 3.3, or now with 3.4 even multiple
inet and/or inet6 sockets can be specified), then the accept() should
be able to wait on multiple sockets. As this is not possible, the
solution is to use a select() on all sockets, and when one is ready,
do an accept() on it.

This is just fine in case of SpamdForkScaling scheduling, as the
master process is the one to do a select(), and activates only one
child process to go for an accept().

The trouble strikes when spamd has to listen on multiple sockets
*and* --round-robin is specified, so that child processes are
autonomous in deciding (with a help of a kernel) who gets the job.

There are two bugs there, one is easy to fix, the other is
more troublesome.

The easy to fix case is that the select() in a child process should
*not* use a timeout (0.1 seconds currently) when --round-robin
is specified, because requests are not scheduled by a master process,
so the client connect request may not yet be there when a child
process comes to its select() call. This was the problem reported
by Jan Hejl on the dev mailing list on 2014-01-28.

The other bug may not be so obvious, and has been lurking in code
ever since spamd got the ability to listen on multiple sockets
(certainly in 3.3, probably in 2.x or even earlier). This bug is
getting more exposure now that spamd can listen on any number of
sockets.

As the code stands now, all autonomous idle child processes call
a select() in case of multiple sockets. When a request comes in,
*all* of them see a socket ready and move on to accept() on it.
Thanks to kernel only one of them completes the accept(), the rest
hang on their accept() call *for that socket only*, and are
unavailable to service requests on other sockets. Effectively this
means that when this happens, only one child process (that one that
took the request and is busy now) will be available to take the
next request on *any* other socket, all the rest are only available
on the socket of the previous request.

A workaround is trivial: don't use --round-robin when listening
on multiple sockets --- or the other way around, don't listen on
multiple sockets when --round-robin is specified.

The solution is to introduce an explicit lock *before* a call
to select() in a child process (and not depend on an implicit
lock by the accept() ). This way the lock will decide which of
the autonomous child processes will take the next request.
This arbitration will take place when a child process becomes
idle (not when the connect request comes it, as it is now).

The Net::Server uses the same locking principle, so there's nothing
new about it. Seems the only question is what kind of locking to use,
apparently Windows does not / did not offer flock mechanism and
a pipe locking can be used there.
Comment 1 Kevin A. McGrail 2014-01-30 18:18:18 UTC
From discussing with Mark, this is a blocker for 3.4.0

Mark:
> What used to be a rarely-
> occuring bug that went by unnoticed, will turn into a likely stall with
> only one process remaining available for all but one socket (e.g. on a
> dual-stack host with spamd listening on both IP addresses of a loopback
> interface).
>
> I'd say it is a blocker. We may not bother with Windows-locking and
> just implement a simple flock, which is cheap and works well on Unix
> using a local (non-NFS) lock file, and automatically unlocks if a process
> happens to crash while holding a lock.


My only thought perhaps them we should make an if (windows) loop and if ip4 and v6, then round-robin is not available option as well? 

Beyond that, the work around is trivial and I don't think has a huge impact on the product. Looking at bug 4594, I think is the one I'm remembering.
Comment 2 Mark Martinec 2014-01-31 17:00:32 UTC
> > I'd say it is a blocker. We may not bother with Windows-locking and
> > just implement a simple flock, which is cheap and works well on Unix
> > using a local (non-NFS) lock file, and automatically unlocks if a process
> > happens to crash while holding a lock.
> 
> My only thought perhaps them we should make an if (windows) loop and
> if ip4 and v6, then round-robin is not available option as well?

Some digging shows that Perl's flock on Windows is just fine since
Windows XP. It may lack the LOCK_NB (nonblocking) option, but we
don't need it.

Also the perlport man page says:
  flock   Not implemented (VMS, RISC OS, VOS)
i.e. it does not mention Windows.

So this concern was apparently a very old information.


I don't remember exactly why the 'managed' scheduling was introduced,
seems to me the only reason is to provide dynamic scaling of the
number of child processes. When one wants a fixed number of processes,
I think the 'autonomous' is just as good, if no better (for its
simplicity).

Here is now my change. I tried to make no impact on the 'managed'
scheduling (no --round-robin), locking is only active when there are
multiple sockets *and* --round-robin (autonomous child processes)
is used.

Also it adds some more informative debug logging, a status check
in SpamdForkScaling.pm, and cosmetics
(Mail::SpamAssassin::Util::untaint_file_path -> untaint_file_path)

trunk:
Bug 6996 - spamd --round-robin conflicts with multiple listen sockets
  Sending lib/Mail/SpamAssassin/SpamdForkScaling.pm
  Sending spamd/spamd.raw
Committed revision 1563172.
Comment 3 Mark Martinec 2014-01-31 17:05:20 UTC
Created attachment 5182 [details]
Proposed patch - adds locking across select+accept on multiple sockets
Comment 4 RW 2014-02-02 15:32:59 UTC
(In reply to Mark Martinec from comment #2)

> I don't remember exactly why the 'managed' scheduling was introduced,
> seems to me the only reason is to provide dynamic scaling of the
> number of child processes. When one wants a fixed number of processes,
> I think the 'autonomous' is just as good, if no better (for its
> simplicity).

The original reason was better performance under paging. By assigning work to the free child with the lowest pid, a number of "hot" processes can retain their resident memory and the higher pid children can be paged-out without much affect on performance. Also since most relevant OS's that support forking assign increasing PIDs, some most-recently created children will remain in an immediate post-fork state reducing SA's overall resident memory usage.  In comparison round-robin or random child allocation is close to worse-case in all respects.
Comment 5 Kevin A. McGrail 2014-02-04 14:38:24 UTC
So to follow up RW's comments, Round-robin is a "not recommended" option anyway but since the patch is done do we want to consider it a fix and move on?  Mark, I believe you committed it.  Do I need to make an rc6 for testing?
Comment 6 Mark Martinec 2014-02-04 15:38:14 UTC
(In reply to Kevin A. McGrail from comment #5)
> So to follow up RW's comments, Round-robin is a "not recommended" option
> anyway but since the patch is done do we want to consider it a fix
> and move on? Mark, I believe you committed it.
> Do I need to make an rc6 for testing?

I was hoping for some feedback from Jan Hejl who reported
the issue originally. But yes, the patch has been committed,
I've tested a couple of scenarios (on Unix, not Windows)
with multiple clients, with and without --round-robin,
observing the debug log. Seems to work as expected - although
I'm not using spamd in production (running amavis here), so
it can't hurt to do another RC.


Not directly relevant to this problem report, but regarding
the comment #4: I agree with the explanation there, but need to
add a remark. When the number of child processes roughly matches
the load so that most of them are busy most of the time, and
the number of processes does not exceed the host's memory size,
then autonomous/random scheduling is no worse than the
'managed' approach. On the other hand, if the number of child
processes is large and a sizable share of them are idle, so
they get paged out, then in my opinion the problem is in the
configuration which should not allow more processes to exist
than a host can handle, and not many more than are actually needed.
In the end, it all boils down to how SpamAssassin is integrated
with a mailing system (before-queue or after-queue with MTA,
or invoked at the time of a mail delivery to a mailbox, or when
a mail client accesses its mailbox).
Comment 7 Jan Hejl 2014-02-04 16:23:30 UTC
The reason why I use --round-robin option is because of "static" child proccesses allocation. I run spamd with certain amount of childs that resides in memory prepared for incoming connections. Specially when there's an incoming peak on spamd it tooked some time to fork new childs. But this was happening in the past and i haven't been testing it without --round-robin since 2009.

Please give me more time to do some tests, i'll let you know.
Comment 8 RW 2014-02-04 17:13:02 UTC
You can have a fixed number of children without using round-robin just by setting --min-children.

If the best that can be said about round-robin is that in some circumstances it's as good as the managed version then what's the point in maintaining it?
Comment 9 Jan Hejl 2014-02-04 18:40:55 UTC
(In reply to RW from comment #8)
> You can have a fixed number of children without using round-robin just by
> setting --min-children.

Actually this doesn't work for me. With --min-children=20 (or any other value) spamd starts only with two childs.

What is proper syntax to start spamd with 20 idle childs?
Comment 10 Mark Martinec 2014-02-04 19:14:31 UTC
(In reply to Jan Hejl from comment #9)
> (In reply to RW from comment #8)
> > You can have a fixed number of children without using round-robin just by
> > setting --min-children.
> 
> Actually this doesn't work for me. With --min-children=20 (or any other
> value) spamd starts only with two childs.

It doesn't work for me too in a 'managed' variant, didn't work with
version 3.3.2 either. Probably related, I remember there were complaints
about stability of managing child processes where --min-children is
the same as --max-children. Should be alright with --round-robin.
Comment 11 RW 2014-02-05 00:27:10 UTC
Created attachment 5183 [details]
patch to fix min-children in spamd
Comment 12 RW 2014-02-05 00:38:43 UTC
Or you can set max-spare to the same value as max-children, the number children never drops below max-spare. I'd forgotten that min-children is broken.
Comment 13 Mark Martinec 2014-02-05 01:36:17 UTC
> Created attachment 5183 [details]
> patch to fix min-children in spamd

> --- spamd/spamd.raw     (revision 1564602)
> +++ spamd/spamd.raw     (working copy)
> @@ -861,6 +861,9 @@
>    if ($childlimit > $opt{'max-spare'}) {
>      $childlimit = $opt{'max-spare'};
>    }
> +  if ($childlimit < $opt{'min-children'}) {
> +    $childlimit = $opt{'min-children'};
> +  }

Thanks, makes sense, does work.

Bug 6996 #11: spamd to honour --min-children
  Sending spamd/spamd.raw
Committed revision 1564604.
Comment 14 Kevin A. McGrail 2014-02-05 08:04:44 UTC
rc6 is out now.  Please make all haste in testing it as we would very much like to build 3.4.0 and get votes to release it down by the 10th.
Comment 15 Mark Martinec 2014-02-05 19:05:18 UTC
3.4.0-RC6 looks good. Closing.