Group
Extension

Object-RateLimiter/lib/Object/RateLimiter.pm

package Object::RateLimiter;
$Object::RateLimiter::VERSION = '1.004001';
use strictures 2;

use Carp 'confess';
use List::Objects::WithUtils 'array';
use Scalar::Util 'blessed';
use Time::HiRes  'time';

use namespace::clean;

use overload
  '&{}'    => sub {
    my $self = shift;
    sub { $self->delay }
  },
  fallback => 1;

use Object::ArrayType::New
  [ events  => '', seconds => 'SECS', _queue => 'QUEUE' ];
sub seconds  { $_[0]->[SECS]   }
sub events   { $_[0]->[EVENTS] }
sub _queue   { $_[0]->[QUEUE]  }

use Class::Method::Modifiers;
around new => sub {
  my ($orig, $class) = splice @_, 0, 2;
  my $self = $class->$orig(@_);
  confess "Constructor requires 'seconds =>' and 'events =>' parameters"
    unless defined $self->seconds and defined $self->events;
  $self->[QUEUE] = array(@{ $self->_queue })
    if $self->_queue and ref $self->_queue eq 'ARRAY';
  $self
};

sub clone {
  my ($self, %params) = @_;
  $params{events}  = $self->events  unless defined $params{events};
  $params{seconds} = $self->seconds unless defined $params{seconds};
  $params{_queue}  = $self->_queue->copy if $self->_queue;
  $self->new(%params);
}

{ no warnings 'once'; *TO_JSON = *export }
sub export {
  my ($self) = @_;
  +{
    events   => $self->events, 
    seconds  => $self->seconds, 
    _queue   => $self->_queue->unbless,
  }
}

sub delay {
  my ($self) = @_;
  my $thisq  = $self->[QUEUE] ||= array;
  my $ev_limit = $self->events;

  if ((my $ev_count = $thisq->count) >= $ev_limit) {
    my $oldest_ts = $thisq->get(0);

    my $delayed = ( 
      $oldest_ts 
      + ( $ev_count * $self->seconds / $ev_limit ) 
    ) - time;

    $delayed > 0 ? return($delayed) : $thisq->shift
  }

  $thisq->push( time );

  0
}


sub clear { $_[0]->[QUEUE] = undef; 1 }

sub expire {
  my ($self) = @_;
  return unless $self->is_expired;
  $self->clear
}

sub is_expired {
  my ($self) = @_;
  my $thisq  = $self->_queue   || return;
  my $latest = $thisq->get(-1) || return;

  time - $latest > $self->seconds
}

print
  qq[<Gilded> Every girlfriendless introvert is now on a list\n],
  qq[<c[_]> already was^\n],
  qq[<Gilded> Well yeah the World of Warcraft subscriber database],
  qq[ but I meant like a government thing\n]
unless caller; 1;

=pod

=for Pod::Coverage EVENTS QUEUE SECS TO_JSON

=head1 NAME

Object::RateLimiter - A flood control (rate limiter) object

=head1 SYNOPSIS

  use Object::RateLimiter;

  my $ctrl = Object::RateLimiter->new(
    events  => 3,
    seconds => 5
  );

  # Run some subs, as a contrived example;
  # no more than 3 in 5 seconds, per our constructor above:
  my @work = (
    sub { "foo" },  sub { "bar" },
    sub { "baz" },  sub { "cake" },
    # ...
  );

  while (my $some_item = shift @work) {
    if (my $delay = $ctrl->delay) {
      # Delayed $delay (fractional) seconds.
      # (You might want Time::HiRes::sleep, or yield to event loop, etc)
      sleep $delay
    }
    print $some_item->()
  }

  # Clear the event history if it's stale:
  $ctrl->expire;

  # Clear the event history unconditionally:
  $ctrl->clear;

  # Same as calling ->delay:
  my $delayed = $ctrl->();

=head1 DESCRIPTION

This is a generic rate-limiter object, implementing the math described in
L<http://www.perl.com/pub/2004/11/11/floodcontrol.html> via light-weight
array-type objects.

The algorithm is fairly simple; the article linked above contains an in-depth
discussion by Vladi Belperchinov-Shabanski (CPAN:
L<http://www.metacpan.org/author/CADE>):

  $delay =
    ( 
      $oldest_timestamp + 
      ( $seen_events * $limit_secs / $event_limit ) 
    )
    - time()

This module uses L<Time::HiRes> to provide support for fractional seconds.

See L<Algorithm::FloodControl> for a similar module with a functional
interface & persistent on-disk storage features (for use with CGI
applications).

=head2 new

  my $ctrl = Object::RateLimiter->new(
    events  => 3,
    seconds => 5
  );

Constructs a new rate-limiter with a clean event history.

Accepts params as a list or a single HASH.

=head2 clear

  $ctrl->clear;

Clear the event history.

=head2 clone

  my $new_ctrl = $ctrl->clone( events => 4 );

Clones an existing rate-limiter; new options can be provided, overriding
previous settings. 

The new limiter contains a clone of the event history; the old rate-limiter is
left untouched.

=head2 delay

  if (my $delay = $ctrl->delay) {
    sleep $delay;  # ... or do something else
  } else {
    # Not delayed.
    do_work;
  }

  # Same as calling ->delay:
  my $delay = $ctrl->();

The C<delay()> method determines if some work can be done now, or should wait.

When called, event timestamps are considered; if we have exceeded our limit,
the delay in (possibly fractional) seconds until the event would be
allowed is returned.

A return value of 0 indicates that the event does not need to wait.

=head2 events

Returns the B<events> limit the object was constructed with.

=head2 expire

  $ctrl->expire;

Clears the event history if L</is_expired> is true.

Returns true if L</clear> was called.

(You're not required to call C<expire()>, but it can be useful to save a
little memory.)

=head2 export

  my $opts = $ctrl->export;
  # ... later, perhaps after storing/retrieving ...
  my $recreated = Object::RateLimiter->new($opts);

Exports the current state of the rate-limiter as a reference to a hash; the
exported hash can be fed to L</new> to recreate the rate-limiter.

This is useful if the rate-limiter's state must be stored persistently. 

=head2 is_expired

Returns true if the last seen event is outside of our time window (in other
words, the event history is stale) or there is no event history.

Also see L</expire>

=head2 seconds

Returns the B<seconds> limit the object was constructed with.

=head1 AUTHOR

Jon Portnoy <avenj@cobaltirc.org>

Based on the math from L<Algorithm::FloodControl> as described in an article
written by the author:
L<http://www.perl.com/pub/2004/11/11/floodcontrol.html>

Licensed under the same terms as Perl.

=cut


Powered by Groonga
Maintained by Kenichi Ishigaki <ishigaki@cpan.org>. If you find anything, submit it on GitHub.