#!/usr/bin/perl -Tw
################################################################################
package Apache::AxKit::CacheCleaner;
# $Id: CacheCleaner.pm,v 1.1 2006/02/22 00:26:44 ken Exp $

=pod

=head1 NAME

Apache::AxKit::CacheCleaner - delete old files from AxKit's cache directory

=head1 SYNOPSIS

Method 1: run it from a cron job (recommended method):

    axkit-cache-cleaner --max-age="1 month" --cache-dir=/var/cache/axkit

Method 2: configure it as an Apache cleanup handler in httpd.conf:

    AxCacheDir /var/cache/axkit
    PerlCleanupHandler +Apache::AxKit::CacheCleaner
    PerlSetVar AxCacheCleanerFrequency "1 day"
    PerlSetVar AxCacheCleanerMaxAge    "1 month"

To enable file locking (for either method):

    AxCacheModule +Apache::AxKit::CacheCleaner

You also need to patch AxKit's cache module to enable file locking.

=head1 DESCRIPTION

This module searches AxKit's cache directory and deletes files whose
last access time is older than a specific maximum age.  For example,
you could run it nightly in a cron job and instruct it to delete files
older than one month.  Alternatively, you can configure it to run
periodically during Apache's cleanup phase.

The maximum age of files to keep can be specified in terms of the
following units:

    second, minute, hour, day, month, year

Fractional units are allowed, and spaces between the number and the
unit are optional, that is, "C<1.5months>" is allowed as well as
"S<C<1.5 months>>".  The unit can be specified as singular ("S<C<1
month>>") or plural ("S<C<2 months>>").

File locking can be enabled so that a file is not deleted from the
cache when it is about to be used again to serve a request.  This
locking avoids the browser displaying a "S<C<404 Not Found>>" error
message.  To enable it, two steps are required:

=over 4

=item 1.

Configure AxKit to use this CacheCleaner as its caching module in
httpd.conf:

    AxCacheModule +Apache::AxKit::CacheCleaner

=item 2.

Patch AxKit to allow a custom module to override the initialization of
the cache.  See below for L<details|/Patching AxKit>.

=back

=head2 Note about AxCacheDir

It is recommended that you set the configuration directive AxCacheDir
to use a specific location for AxKit's cache directory.  If you do not
set AxCacheDir, then AxKit automatically creates a cache subdirectory
named ".xmlstyle_cache" under the current directory whenever it
processes a document.  This CacheCleaner will clean out these
subdirectories when configured as an Apache cleanup handler (assuming
some document located above the cache subdirectory is visited
periodically).  However, when run from a cron job, you must specify
all the cache subdirectories as in this example:

    axkit-cache-cleaner --max-age=1month \
        --cache-dir=/var/www/.xmlstyle_cache \
        --cache-dir=/var/www/subdir1/.xmlstyle_cache \
        --cache-dir=/var/www/subdir2/.xmlstyle_cache

=head2 Overview of AxKit Caching

Before describing how the cache cleaner operates, this section
presents a short overview of how AxKit stores cache files.

AxKit assigns a 32-character hexadecimal key for caching a document.
This key is generated based on the pathname of the source document and
on other data such as hostname, port, media, and style.  The first
four characters of this key are used to create two subdirectory levels
under the cache directory, and the document is stored under this
subdirectory with a filename using the remaining 28 characters of the
key.  For example, a document could have a key of
8c6577c54cef4c2ca1cbdf319516e6d5, and AxKit would store the document
in this location:

  cachedir/8c/65/77c54cef4c2ca1cbdf319516e6d5

Additional files related to this cache entry are also stored in the
cache.  The content type is stored in a file with the same key and an
extension of ".type".  A list of dependencies is stored in a file with
a different key, which is derived from the given key appended with
'.depends' (but this string is not used as the file name extension).

Furthermore, XSP stores its Perl code in a cache file with a key
derived from the self-generated Perl package name appended with
'compiled XSP'.  Output from the XSP script is stored in a cache file
with a key derived from the self-generated Perl package name appended
with any user-defined cache parameters (or '' if none defined).
However, the output cache file is not used unless it is separately
enabled by defining a method for C<has_changed> in your XSP script as
described in the L<http://axkit.org/wiki/view/AxKit/XSPGuide>.

=head2 Cache Cleaning Details

This CacheCleaner module searches for files under the two levels of
subdirectories, but only for 2-character hexadecimal subdirectory
names and only for 28-character hexadecimal filenames (with an
optional extension).  It compares the access timestamp to the current
time, and if it is too old then the file is deleted.  All types of old
files are deleted: cached documents, content-type files, dependency
files, XSP cache files, and anything else that matches the
28-character hexadecimal pattern.

While the files are being deleted, a list of empty subdirectories is
generated, and these subdirectories are removed after all the old
files are deleted.

When configured as an Apache Cleanup handler, this module uses the
timestamp of a "last_cleaned" file in the cache directory to keep
track of when the cache was last cleaned.  It will not clean the cache
more often than the period specified by AxCacheCleanerFrequency.  When
it is time to clean the cache, an exclusive lock is obtained on the
"last_cleaned" file so that only one Apache process/thread is used to
clean the cache and the others are free to continue serving requests.

=head2 Do I Really Need to Clean AxKit's Cache?

The AxKit cache improves performance, so you should not clean it
unless really necessary.  If your web site is made up of static XML
files, then there should be no reason to clean the cache (except maybe
after a large update when lots of old source documents are deleted or
renamed).  Even if your web site includes some dynamic pages such as
XSP, there should still be no reason to clean the cache because you
would have only a limited number of source files.  (Only the source
code for your dynamic page is cached by default.  The generated output
document is not served from the cache without specific configuration
to support it).

The main reason to clean AxKit's would be if your web site caches
individualized pages (per user) and you have a large user base.  In
this situation, the cache would continue to grow as more and more
users made use of your web site.  This is especially true if the
dynamically generated individual pages are dated.  For example, if
your web site generates monthly account statements for thousands of
users on-the-fly, then you should probably clean out old statements
from the cache directory periodically before they fill up the disk.

=head2 Locking Details

Without some form of cache file locking, a race condition exists where
it is possible that this CacheCleaner module will delete a cache file
just as AxKit is preparing to serve it to the browser.  Here is the
scenario: assume there is a cache file old enough to be deleted.
Suppose a user requests the resource corresponding to this cache file.
AxKit checks the dependencies and decides to use the cache file.  But
if the cache cleaner deletes it right after AxKit has performed its
checks and right before AxKit turns over the cache file name to Apache
to be served, a "S<C<404 Not Found>>" error will be generated.

To avoid this possibility, locking has been implemented so that this
module does not delete an old cache file if AxKit is in the process of
considering whether to use it.

Two stages of locking are used.  A primary lock file is used to lock
out the removal of secondary cache I<directories>.  This lock file is
located in the top-level cache directory.  Secondary lock files are
used to lock out the deletion of cache I<files> under the secondary
directories.  These lock files are located under the secondary
directories (one in each directory).

=head3 Locking During Request

For each request, a shared lock is obtained on the primary lock file
just before AxKit initializes the cache object.  This ensures that a
secondary subdirectory will not be removed before AxKit has a chance
to review a potential cache file under it.  After AxKit has
initialized the cache object (and created the secondary subdirectory
if it did not already exist), a shared lock is obtained on the
secondary lock file, and then the primary lock is released.  The
secondary lock ensures that a cache file will not be deleted while
AxKit considers whether to use it.  After AxKit has finished serving
the request, that is, after it has sent the document to the browser,
the secondary lock is released.

=head3 Locking During Cleanup

When the cache cleaner is scheduled to run, it scans each secondary
subdirectory one at a time and makes a list of cache files with a last
access time older than the specified maximum age.  For each
subdirectory, it obtains an exclusive lock on the secondary lock file,
deletes the old cache files, and releases the lock.  If all the files
were deleted, it adds the subdirectory to the list of empty
directories which will be removed later.  After the cache cleaner
finishes scanning all the subdirectories and deleting all the old
cache files, it then obtains an exclusive lock on the primary lock
file, removes any empty subdirectories, and releases the lock.

=head2 Patching AxKit

It is necessary to patch AxKit to allow this module to override the
initialization of the cache so that locks can be obtained before cache
subdirectories are created.  The included patch can be applied to
versions 1.6.X and 1.7.0 of AxKit.  Without the patch, locking cannot
be enabled and a race condition exists where this cleanup module may
delete a cache file just as it is about to be served, leading to a
"S<C<404 Not Found>>" error message reported to the browser.

=head3 Why is the patch needed?

AxKit provides four directives "to specify a new class to replace the
one being used for certain operations."  These are:

    AxConfigReader     default: Apache::AxKit::ConfigReader
    AxContentProvider  default: Apache::AxKit::Provider
    AxStyleProvider    default: Apache::AxKit::Provider
    AxCacheModule      default: Apache::AxKit::Cache

The key phrase is "certain operations".  Unlike the other modules, the
default AxCacheModule does not provide an overridable method for the
initialization of that object.  Specifically, the default
AxConfigReader provides a C<get_config> method, and the default
AxContentProvider and AxStyleProvider modules provide an C<init>
method.  However, the default AxCacheModule does its initialization in
the C<new> method which cannot be overridden.

=head3 What does the patch do?

The included patch moves most of the initialization logic out of the
AxCacheModules's C<new> method and places it into an C<init> method
which can be overridden.  This patch allows this CacheCleaner module
to obtain locks on the cache before it is initialized.

=head3 Do I really need to install the patch?

All the patch does is get rid of the possibility of a "S<C<404 Not
Found>>" error message appearing.  To decide if you need to install
the patch you need to consider: (1) how likely is this error to
happen, and (2) if it does happen what ill effects will it have?  If
it is unlikely that the error will happen, and if it is ok for a user
to receive the error message once in a while, then you do not need to
install the patch.

Here are some recommendations if you decide not to install the patch:

=over 4

=item *

Run the cache cleaner as a cron job during low-usage hours.  That is,
do not configure the cache cleaner as an Apache Cleanup handler,
because that would cause it to run most likely during high-usage
hours, which is the most likely time for the cleaner to delete a file
when it is about to be served.

=item *

Increase the MaxAge parameter to make it even less likely that a user
would request an old page just as it was about to be deleted.

=item *

After running a few days without the patch, check your web log files
for the "404" HTTP status code to see how often (if any) the error
appears.

=back

=cut

################################################################################
use strict;
use Apache ();
use Apache::Constants ();
use Apache::AxKit::Cache ();
use Symbol ();
use Fcntl qw( :flock );
@Apache::AxKit::CacheCleaner::ISA = qw( Apache::AxKit::Cache );

$Apache::AxKit::CacheCleaner::VERSION = '0.90';

my $message_do_not_delete = <<EOT;
Do not delete this file; it is used by Apache::AxKit::CacheCleaner
to coordinate the deletion of old cache files.
EOT

################################################################################
sub init {
    my $self = shift;

    if ( $AxKit::Cfg->NoCache ) {
	$self->SUPER::init( @_ );
	return;
    }

    my $cachedir = $AxKit::Cfg->CacheDir;
    if ( not -e $cachedir and not mkdir( $cachedir, 0777 ) ) {
	$self->SUPER::init( @_ );
	return;
    }

    # Get a "cache directory primary lock" before we create any
    # subdirectories.
    my $primary_lockfile = "$cachedir/lockfile";
    my $primary_lock = Apache->gensym;
    my $pre_existing = -e $primary_lockfile;
    # Note: open file in "read/append" mode so that the file is
    # created if it does not already exist but also so that it is not
    # truncated if it does already exist.
    open( $primary_lock, "+>>$primary_lockfile" )
      or die "AxKit CacheCleaner: could not open file $primary_lockfile: $!";
    flock( $primary_lock, LOCK_SH )
      or die "AxKit CacheCleaner: could not lock file $primary_lockfile: $!";
    unless ( $pre_existing ) {
	seek( $primary_lock, 0, 0 );
	truncate( $primary_lock, 0 );
	print $primary_lock $message_do_not_delete;
    }

    # Initialize cache object.
    $self->SUPER::init( @_ );
    if ( $self->no_cache ) {
	close( $primary_lock )
	  or die "AxKit CacheCleaner: could not close file $primary_lockfile: $!";
	return;
    }

    # Get a "subdirectory secondary lock" and hold on to it until
    # request is processed.
    ( my $secondary_lockfile = $self->{file} ) =~ s![^/]*$!lockfile!;
    my $secondary_lock = Symbol::gensym;
    $pre_existing = -e $secondary_lockfile;
    open( $secondary_lock, "+>>$secondary_lockfile" )
      or die "AxKit CacheCleaner: could not open file $secondary_lockfile: $!";
    flock( $secondary_lock, LOCK_SH )
      or die "AxKit CacheCleaner: could not lock file $secondary_lockfile: $!";
    unless ( $pre_existing ) {
	seek( $secondary_lock, 0, 0 );
	truncate( $secondary_lock, 0 );
	print $secondary_lock $message_do_not_delete;
    }
    my $release_lock = sub {
	close( $secondary_lock )
	  or die "AxKit CacheCleaner: could not close file $secondary_lockfile: $!";
    };
    # NOTE: a deadlock will occur if the cleanup subroutine
    # ($release_lock registered in the next statement) runs after the
    # PerlCleanupHandler (the "sub handler" defined below).  I could
    # not find documentation of which subroutine runs first, but my
    # limited testing showed that register_cleanup runs before
    # PerlCleanupHandler, so hopefully we are ok.
    $self->{apache}->register_cleanup( $release_lock );

    # Release "cache directory lock" now that we have a "subdirectory lock".
    close( $primary_lock )
      or die "AxKit CacheCleaner: could not close file $primary_lockfile: $!";
}

################################################################################
# This subroutine is the Apache cleanup handler to periodically check
# for old cache files and delete them.
#-------------------------------------------------------------------------------
sub handler {
    my $r = shift;

    my $axkit_config = Apache::AxKit::ConfigReader->new( $r );
    my $cachedir = $axkit_config->CacheDir;
    my $frequency = $r->dir_config( 'AxCacheCleanerFrequency' );
    $frequency = '1 day' unless defined $frequency;
    _convert_time( \$frequency )
      or die "AxKit CacheCleaner: invalid AxCacheCleanerFrequency: $frequency";
    my $last_cleaned_file = "$cachedir/last_cleaned";
    if ( my @stat = stat( $last_cleaned_file ) ) {
	my $last_cleaned = $stat[9]; # $stat[9] is mtime, i.e. last modify time
	if ( time < $last_cleaned + $frequency ) {
	    return Apache::Constants::DECLINED;
	}
    }
    elsif ( -e $last_cleaned_file ) {
	die "AxKit CacheCleaner: could not stat file $last_cleaned_file: $!";
    }

    my $last_cleaned_fh = Apache->gensym;
    my $pre_existing = -e $last_cleaned_file;
    open( $last_cleaned_fh, "+>>$last_cleaned_file" )
      or die "AxKit CacheCleaner: could not open file $last_cleaned_file: $!";
    unless ( flock( $last_cleaned_fh, LOCK_EX|LOCK_NB ) ) {
	AxKit::Debug( 10, "AxKit CacheCleaner: declining (another process is cleaning this cache directory)" );
	close( $last_cleaned_fh )
	  or die "AxKit CacheCleaner: could not close file $last_cleaned_file: $!";
	return Apache::Constants::DECLINED;
    }
    unless ( $pre_existing ) {
	seek( $last_cleaned_fh, 0, 0 );
	truncate( $last_cleaned_fh, 0 );
	print $last_cleaned_fh <<EOT;
The timestamp on this file is when this cache directory was last
cleaned by Apache::AxKit::CacheCleaner.
EOT
    }

    my $max_age = $r->dir_config( 'AxCacheCleanerMaxAge' );
    $max_age = '1 month' unless defined $max_age;
    AxKit::Debug( 2, "AxKit CacheCleaner: deleting files older than $max_age from $cachedir" );
    my $verbose = $axkit_config->DebugLevel > 4 ? 'verbose' : '';
    clean( $cachedir, $max_age, $verbose );
    utime( time, time, $last_cleaned_file )
      or die "AxKit CacheCleaner: could not time stamp $last_cleaned_file: $!";
    close( $last_cleaned_fh )
      or die "AxKit CacheCleaner: could not close file $last_cleaned_file: $!";

    return Apache::Constants::OK;
}

################################################################################
# Note: this subroutine must be runnable from outside of Apache to
# support cron job deletion of old cache files.
#-------------------------------------------------------------------------------
sub clean {
    my( $cachedir, $max_age, $verbose ) = @_;

    if ( -d $cachedir and $cachedir =~ m!(.*)! ) {
	$cachedir = $1; # untaint
    }
    elsif ( not -e $cachedir ) {
	die "AxKit CacheCleaner: CacheDir does not exist: $cachedir";
    }
    else {
	die "AxKit CacheCleaner: CacheDir is not a directory: $cachedir";
    }

    $max_age = '1 month' unless defined $max_age;
    _convert_time( \$max_age )
      or die "AxKit CacheCleaner: invalid AxCacheCleanerMaxAge: $max_age";

    my $dir = $cachedir;
    my $dh = Symbol::gensym;
    opendir( $dh, $dir )
      or die "AxKit CacheCleaner: could not open directory $dir: $!";
    my @primaries =
      grep { m!^([0-9a-f]{2})$! and $_ = $1 and -d "$dir/$_" } readdir( $dh );
    closedir( $dh )
      or die "AxKit CacheCleaner: could not close directory $dir: $!";

    my @empty_directories;
    foreach my $primary ( @primaries ) {
	my $dir = "$cachedir/$primary";
	my $dh = Symbol::gensym;
	opendir( $dh, $dir )
	  or die "AxKit CacheCleaner: could not open directory $dir: $!";
	my @secondaries =
	  grep { m!^([0-9a-f]{2})$! and $_ = $1 and -d "$dir/$_" }
	    readdir( $dh );
	closedir( $dh )
	  or die "AxKit CacheCleaner: could not close directory $dir: $!";

	my $empty_secondaries = 0;
	foreach my $secondary ( @secondaries ) {
	    my $dir = "$cachedir/$primary/$secondary";
	    my $dh = Symbol::gensym;
	    opendir( $dh, $dir )
	      or die "AxKit CacheCleaner: could not open directory $dir: $!";
	    my @cachefiles =
	      grep { m!^([0-9a-f]{28}(?:\..*)?)$! and $_ = $1 and -f "$dir/$_" }
		readdir( $dh );
	    closedir( $dh )
	      or die "AxKit CacheCleaner: could not close directory $dir: $!";
	    my $total = scalar @cachefiles;
	    my $deleted =
	      _delete_old_cachefiles( $dir, $max_age, \@cachefiles, $verbose );
	    if ( $deleted == $total ) {
		push( @empty_directories, $dir );
		++$empty_secondaries;
	    }
	}
	push( @empty_directories, $dir ) if $empty_secondaries == @secondaries;
    }

    _remove_empty_directories( $cachedir, \@empty_directories, $verbose );
}

################################################################################
sub _delete_old_cachefiles {
    my( $dir, $max_age, $cachefiles, $verbose ) = @_;

    my $deleted = 0;
    my( $secondary_lockfile, $secondary_lock );
    while ( @$cachefiles ) {
	my $cachefile = shift @$cachefiles;
	my $file = "$dir/$cachefile";
	my @stat = stat( $file );
	unless ( @stat ) {
	    if ( -e $file ) {
		warn "AxKit CacheCleaner: could not stat file $file: $!";
	    }
	    else {
		++$deleted;
	    }
	    next;
	}
	my $age = time - $stat[8]; # $stat[8] is atime, i.e. last access time
	next unless $age >= $max_age;
	if ( $secondary_lock ) {
	    warn "AxKit CacheCleaner: deleting file $file (age $age >= $max_age)\n"
	      if $verbose;
	    if ( unlink $file or not -e $file ) {
		++$deleted;
	    }
	    else {
		warn "AxKit CacheCleaner: could not delete file $file: $!";
	    }
	}
	else {
	    # Get a "subdirectory secondary lock" before we delete any
	    # files.  Note: we need to re-stat the current file after
	    # we get the lock because the file may have been updated
	    # while we were waiting for the lock, in which case it
	    # should not be deleted.  So unshift it back onto the list
	    # of cachefiles so it can be checked again.
	    unshift( @$cachefiles, $cachefile );
	    $secondary_lockfile = "$dir/lockfile";
	    $secondary_lock = Symbol::gensym;
	    my $pre_existing = -e $secondary_lockfile;
	    open( $secondary_lock, "+>>$secondary_lockfile" )
	      or die "AxKit CacheCleaner: could not open file $secondary_lockfile: $!";
	    # TODO: maybe use SIG{ALRM} to time out on getting exclusive locks
	    flock( $secondary_lock, LOCK_EX )
	      or die "AxKit CacheCleaner: could not lock file $secondary_lockfile: $!";
	    unless ( $pre_existing ) {
		seek( $secondary_lock, 0, 0 );
		truncate( $secondary_lock, 0 );
		print $secondary_lock $message_do_not_delete;
	    }
	}
    }
    if ( $secondary_lock ) {
	close( $secondary_lock )
	  or die "AxKit CacheCleaner: could not close file $secondary_lockfile: $!";
    }
    $deleted;
}

################################################################################
sub _remove_empty_directories {
    my( $cachedir, $empty_directories, $verbose ) = @_;

    return unless @$empty_directories;

    # Get a "cache directory primary lock" before we delete any subdirectories.
    my $primary_lockfile = "$cachedir/lockfile";
    my $primary_lock = Symbol::gensym;
    my $pre_existing = -e $primary_lockfile;
    open( $primary_lock, "+>>$primary_lockfile" )
      or die "AxKit CacheCleaner: could not open file $primary_lockfile: $!";
    # TODO: maybe use SIG{ALRM} to time out on getting exclusive locks
    flock( $primary_lock, LOCK_EX )
      or die "AxKit CacheCleaner: could not lock file $primary_lockfile: $!";
    unless ( $pre_existing ) {
	seek( $primary_lock, 0, 0 );
	truncate( $primary_lock, 0 );
	print $primary_lock $message_do_not_delete;
    }

    foreach my $dir ( @$empty_directories ) {
	my $secondary_lockfile = "$dir/lockfile";
	my $secondary_lock;
	if ( -e $secondary_lockfile ) {
	    # A process may still be using this subdirectory, so
	    # wait for a lock before deleting it.
	    $secondary_lock = Symbol::gensym;
	    open( $secondary_lock, "<$secondary_lockfile" )
	      or die "AxKit CacheCleaner: could not open file $secondary_lockfile: $!";
	    # TODO: maybe use SIG{ALRM} to time out on getting exclusive locks
	    flock( $secondary_lock, LOCK_EX )
	      or die "AxKit CacheCleaner: could not lock file $secondary_lockfile: $!";
	}
	my $dh = Symbol::gensym;
	opendir( $dh, $dir )
	  or die "AxKit CacheCleaner: could not open directory $dir: $!";
	my @files = grep { $_ !~ m!^\.\.?$! } readdir( $dh );
	if ( @files == 1 and $files[0] eq 'lockfile' ) {
	    if ( unlink $secondary_lockfile or not -e $secondary_lockfile ) {
		shift @files;
	    }
	    else {
		warn "AxKit CacheCleaner: could not delete file $secondary_lockfile: $!";
	    }
	}
	unless ( @files ) {
	    warn "AxKit CacheCleaner: removing empty directory $dir\n"
	      if $verbose;
	    if ( not rmdir $dir and -e $dir ) {
		warn "AxKit CacheCleaner: could not remove directory $dir: $!";
	    }
	}
	closedir( $dh )
	  or die "AxKit CacheCleaner: could not close directory $dir: $!";
	if ( $secondary_lock ) {
	    close( $secondary_lock )
	      or die "AxKit CacheCleaner: could not close file $secondary_lockfile: $!";
	}
    }

    close( $primary_lock )
      or die "AxKit CacheCleaner: could not close file $primary_lockfile: $!";
}

################################################################################
my %map = ( '' => 1,
            'second' => 1,
	    'minute' => 60,
	    'hour'   => 60*60,
	    'day'    => 60*60*24,
	    'month'  => 60*60*24*365.2425/12,
	    'year'   => 60*60*24*365.2425 );
my $units = join( '|', keys %map, '' );
#-------------------------------------------------------------------------------
# _convert_time can be called in either of the following two ways:
# (1) _convert_time( \$time ) or die "bad time: $time";
# (2) my $converted = _convert_time( $time );
#     die "bad time: $time" unless defined $converted;
sub _convert_time {
    return unless defined $_[0];
    my $time = ref $_[0] ? ${$_[0]} : $_[0];
    if ( $time =~ m!^([+-]?(?:\d+\.?\d*|\.\d+))\s*($units)s?$!o ) {
	$time = $1 * $map{$2};
	if ( ref $_[0] ) {
	    ${$_[0]} = $time;
	    return 1;
	}
	return $time;
    }
    else {
	return undef;
    }
}

################################################################################

=pod

=head1 SEE ALSO

L<AxKit|AxKit>

=head1 AUTHOR

Ken Neighbors <ken@nsds.com>

=head1 COPYRIGHT and LICENSE

Copyright (c) 2006 Ken Neighbors.  All rights reserved.  This module
is free software; you can redistribute it and/or modify it under the
same terms as Perl itself.

=cut

1;
