The Perl Toolchain Summit needs more sponsors. If your company depends on Perl, please support this very important event.
package IPC::Lock::WithTTL;

use strict;
use warnings;

our $VERSION = '0.01';

use Carp;
use Smart::Args;
use Class::Accessor::Lite (
    rw => [qw(ttl)],
    ro => [qw(file kill_old_proc)],
   );
use Fcntl qw(:DEFAULT :flock :seek);

sub new {
    args(my $class,
         my $file          => { isa => 'Str' },
         my $ttl           => { isa => 'Int',  default => 0 },
         my $kill_old_proc => { isa => 'Bool', default => 0 },
        );

    my $self = bless {
        file          => $file,
        ttl           => $ttl,
        kill_old_proc => $kill_old_proc,
        #
        _fh           => undef,
    }, $class;

    return $self;
}

sub _fh {
    args(my $self);

    unless ($self->{_fh}) {
        open $self->{_fh}, '+>>', $self->file or croak $!;
    }

    return $self->{_fh};
}

sub acquire {
    args(my $self,
         my $ttl => { isa => 'Int', optional => 1 },
        );
    $self->ttl($ttl) if $ttl;

    my $fh = $self->_fh;
    flock $fh, LOCK_EX or return;

    seek $fh, 0, SEEK_SET;
    my($heartbeat) = <$fh>;
    $heartbeat ||= "0 0";
    my($pid, $expiration) = split /\s+/, $heartbeat;
    $pid += 0; $expiration += 0;

    my $now = time();
    my $new_expiration;
    my $acquired = 0;
    if ($pid == 0) {
        # Previous task finished successfully
        if ($now >= $expiration) {
            # expired
            $new_expiration = $self->update_heartbeat;
            $acquired = 1;
        } else {
            # not expired
            $acquired = 0;
        }
    } elsif ($pid != $$) {
        # Other task is in process?
        if ($now >= $expiration) {
            # expired (Last task may have terminated abnormally)
            $new_expiration = $self->update_heartbeat;

            if ($self->kill_old_proc && $pid > 0) {
                kill 'KILL', $pid;
            }
            $acquired = 1;
        } else {
            # not expired (Still running)
            $acquired = 0;
        }
    } else {
        # Previous task done by this process
        if ($now >= $expiration) {
            # expired (Last task may have terminated abnormally)
            $new_expiration = $self->update_heartbeat;
            $acquired = 1;
        } else {
            # not expired (Last task may have terminated abnormally)
            $new_expiration = $self->update_heartbeat;
            $acquired = 1;
        }
    }

    flock $fh, LOCK_UN;
    if ($acquired) {
        return wantarray ? (1, { pid => $$,   expiration => $new_expiration })
                         : 1;
    } else {
        return wantarray ? (0, { pid => $pid, expiration => $expiration })
                         : 0;
    }
}

sub release {
    args(my $self);

    $self->update_heartbeat(pid => 0);
    undef $self->{_fh};

    return 1;
}

sub update_heartbeat {
    args(my $self,
         my $pid => { isa => 'Int', default => $$ },
       );

    my $fh = $self->_fh;

    my $expiration = time() + $self->ttl;

    seek $fh, 0, SEEK_SET;
    truncate $fh, 0;
    print {$fh} join(' ', $pid, $expiration)."\n";

    return $expiration;
}

1;

__END__

=encoding utf-8

=head1 NAME

IPC::Lock::WithTTL - run only one process up to given timeout

=head1 SYNOPSIS

    use IPC::Lock::WithTTL;
    
    my $lock = IPC::Lock::WithTTL->new(
        file          => '/tmp/lockme',
        ttl           => 5,
        kill_old_proc => 0,
       );
    
    my($r, $hb) = $lock->acquire;
    
    if ($r) {
        infof("Got lock! yay!!");
    } else {
        critf("Cannot get lock. Try after at %d", $hb->{expiration});
        exit 1;
    }
    
    $lock->release;

=head1 DESCRIPTION

IPC::Lock::WithTTL provides inter process locking feature.
This locking has timeout feature, so we can use following cases:

    * Once send an alert email, don't send same kind of alert email within 10 minutes.
    * We want to prevent the situation that script for failover some system is invoked more than one processes at same time and invoked many times in short time.

=head1 DETAIL

=head2 SEQUENCE

    1. flock a heartbeat file (specified by file param in new) with LOCK_EX
       return if failed to flock.
    2. read a heartbeat file and examine PID and expiration (describe later)
       return if I should not go ahead.
    3. update a heartbeat file with my PID and new expiration.
    4. ACQUIRED LOCK
    5. unlock a lock file.
    6. process main logic.
    7. RELEASE LOCK with calling $lock->release method.
       In that method update a heartbeat file with PID=0 and new expiration.

=head2 DETAIL OF EXAMINATION OF PID AND EXPIRATION

Format of a heartbeat file (lock file) is:

    PID EXPIRATION

Next action table by PID and expiration

    PID       expired?  Next action      Description
    =========================================================================
    not mine  yes       acquired lock*1  Another process is running or
    - - - - - - - - - - - - - - - - - -  exited abnormally (without leseasing
    not mine  no        return           lock).
    -------------------------------------------------------------------------
    mine      yes       acquired lock    Previously myself acquired lock but
    - - - - - - - - - - - - - - - - - -  does not release lock.
    mine      no        acquired lock
    -------------------------------------------------------------------------
    0         yes       acquired lock    Previously someone acquired and
    - - - - - - - - - - - - - - - - - -  released lock successfully.
    0         no        return
    -------------------------------------------------------------------------
    
    *1 try to kill another process if you enable kill_old_proc option in new().

=head1 METHODS

=over 4

=item B<new>($args:Hash)

    file => Str (required)
      File path of heartbeat file. IPC::Lock::WithTTL also flock this file.
    
    ttl  => Int (default is 0)
      TTL to exipire. expiration time set to now + TTL.
    
    kill_old_proc => Boolean (default is 0)
      Try to kill old process which might exit abnormally.

=item B<acquire>(ttl => $TTL:Int)

Try to acquire lock. ttl option set TTL to expire (override ttl in new())

This method returns scalar or list by context.

    Scalar context
    =========================================================================
      Acquired lock successfully
        1
      -----------------------------------------------------------------------
      Failed to acquire lock
        0
    
    List context
    =========================================================================
      Acquired lock successfully
        (1, { pid => PID, expiration => time_to_expire })
        PID is mine. expiration is setted by me.
      -----------------------------------------------------------------------
      Failed to acquire lock
        (0, { pid => PID, expiration => time_to_expire })
        PID is another process. expiration is setted by another process.

=item B<release>()

Update a heartbeat file (PID=0 and new expiration) and release lock.

=back

=head1 AUTHOR

HIROSE Masaaki E<lt>hirose31 _at_ gmail.comE<gt>

=head1 REPOSITORY

L<https://github.com/hirose31/IPC-Lock-WithTTL>

  git clone git://github.com/hirose31/IPC-Lock-WithTTL.git

patches and collaborators are welcome.

=head1 SEE ALSO

L<IPC::Lock|IPC::Lock>

=head1 LICENSE

This library is free software; you can redistribute it and/or modify
it under the same terms as Perl itself.

=cut

# for Emacsen
# Local Variables:
# mode: cperl
# cperl-indent-level: 4
# indent-tabs-mode: nil
# coding: utf-8
# End:

# vi: set ts=4 sw=4 sts=0 :