File Coverage

blib/lib/Schedule/AdaptiveThrottler.pm
Criterion Covered Total %
statement 91 95 95.7
branch 45 68 66.1
condition 11 24 45.8
subroutine 9 9 100.0
pod 3 3 100.0
total 159 199 79.9


line stmt bran cond sub pod time code
1             package Schedule::AdaptiveThrottler;
2              
3 7     7   135244 use warnings;
  7         19  
  7         355  
4 7     7   45 use strict;
  7         17  
  7         696  
5              
6             our $VERSION = '0.06';
7             our $DEBUG = 0;
8             our $QUIET = 0;
9              
10 7     7   51 use Scalar::Util qw(reftype blessed);
  7         24  
  7         739  
11 7     7   43 use Digest::MD5 qw(md5_hex);
  7         26  
  7         1017  
12              
13             require Exporter;
14             our @ISA = qw(Exporter);
15             our @EXPORT_OK = qw(&authorize &set_client);
16             our @EXPORT = qw(
17             SCHED_ADAPTHROTTLE_AUTHORIZED
18             SCHED_ADAPTHROTTLE_BLOCKED
19             );
20             our %EXPORT_TAGS = ( ALL => [ @EXPORT_OK, @EXPORT ] );
21              
22 7     7   74 use constant SCHED_ADAPTHROTTLE_BLOCKED => 0;
  7         12  
  7         503  
23 7     7   54 use constant SCHED_ADAPTHROTTLE_AUTHORIZED => 1;
  7         18  
  7         10547  
24              
25             my $memcached_client; # used for the non-OO form
26              
27             sub set_client {
28 8     8 1 1337 my $client = pop; # keep ordered
29 8         16 my $self = shift;
30 8 50 33     444 die "Invalid storage client object\n"
      33        
31             if ( !blessed($client)
32             || !$client->can('set')
33             || !$client->can('get') );
34 8 100       60 return $memcached_client = $client if !blessed $self; # non-OO form
35 6         31 return $self->{memcached_client} = $client; # Guess what? OO-form
36             }
37              
38             # OO-style
39             sub new {
40 6     6 1 2609 my $class = shift;
41 6         14 my $params;
42 6 100       28 if ( @_ == 1 ) {
43 2 100       13 if ( !blessed $_[0] ) {
44 1         6 $params = shift;
45             }
46             else {
47 1         5 $params->{memcached_client} = shift;
48             }
49             }
50             else {
51 4         13 $params = {@_};
52             }
53 6         26 my $self = bless $params, $class;
54 6 100       64 $self->set_client( $params->{memcached_client} )
55             if $params->{memcached_client};
56 6         40 return $self;
57             }
58              
59             sub authorize {
60              
61 74     74 1 260 my %params;
62             my $self;
63              
64             # Call it as a method or a sub, with a hash or a hashref,
65             # as a class or an instance
66 74 100       252 if ( @_ < 3 ) {
67 54         74 %params = %{ pop() };
  54         294  
68 54         141 $self = shift;
69             }
70             else {
71 20 100       84 $self = shift if ( @_ % 2 );
72 20         85 %params = @_;
73             }
74 74 100       392 my $cur_memcached_client
75             = blessed $self ? $self->{memcached_client} : $memcached_client; # can it get uglier?
76              
77 74         158 my $frozen_time = time;
78 74         103 my %conditions;
79              
80             # Check the conditions
81              
82             my $condition_type;
83 74         152 for my $condition_type_tmp (qw(all either)) {
84              
85 74 50 33     565 if ( exists $params{$condition_type_tmp}
86             && reftype $params{$condition_type_tmp} eq 'HASH' )
87             {
88 74         97 %conditions = %{ $params{$condition_type_tmp} };
  74         250  
89 74 50       400 die "Conditions improperly defined (must be a hashref)"
90             if !%conditions;
91              
92             # Check the parameters
93              
94 74         268 for my $condition_params ( values %conditions ) {
95 74         156 for my $condition_param_key (qw{max ttl message value}) {
96              
97             # message & value are strings (or just anything for 'value'), the rest are integers
98 296 50       741 die
99             "Condition parameter $condition_param_key is '$condition_params->{$condition_param_key}'"
100             if !$condition_params->{$condition_param_key};
101 296 50 100     2199 die "Condition parameter $condition_param_key must be positive integer"
      66        
102             if ( ( $condition_param_key eq 'max' || $condition_param_key eq 'ttl' )
103             && $condition_params->{$condition_param_key} !~ /^[1-9][0-9]*$/ );
104             }
105             }
106 74         128 $condition_type = $condition_type_tmp;
107 74         149 last; # process either 'all' or 'either', not both
108             }
109             }
110 74 50       178 die "No conditions defined"
111             if !$condition_type;
112              
113             # if lockout is defined, use the 'lockout/ban' scheme. if not, we'll use a
114             # bucket algorithm
115 74         134 my $lockout = $params{lockout};
116 74 50 33     487 die "'Lockout' parameter must be positive integer"
117             if ( defined $lockout && $lockout !~ /^[1-9][0-9]*$/ );
118              
119 74         151 my $identifier = $params{identifier};
120 74 50 33     381 die "'Identifier' should be a non-empty string"
121             if ( !defined $identifier || length($identifier) < 1 );
122              
123             # Loop on the conditions. For 'either', we need to find one that is not yet
124             # satisfied, for 'all' we need to find lockouts for all of them
125              
126             # Make the memcached keys a identifier + key name + value
127             # TODO: Retrieve the records in 1 operation with get_multi
128             # @conditions_names = sort keys %conditions;
129             # @keys = map { $_ . '#' . $conditions{$_}->{value} } @conditions_names ) {
130              
131 74         134 my ( $conditions_ok, $conditions_unknown ) = ( 0, 0 );
132 74         130 my $messages_notok = [];
133              
134 74         268 while ( my ( $condition_name, $condition ) = each %conditions ) {
135 74         218 my $memcached_key = $identifier . '#' . $condition_name . '#' . $condition->{value};
136 74 100       202 $memcached_key = md5_hex($memcached_key) if length $memcached_key > 249;
137              
138 74         386 my $record = $cur_memcached_client->get($memcached_key);
139              
140 74 100       947 if ( defined $record ) {
141              
142             # Do we have a 'block' value in the record, in which case we return
143             # a message indicating so. The 'block' record will be automatically
144             # removed from memcached at the object's expiry time, so don't
145             # touch it.
146 59 100       279 if ( $record eq 'block' ) {
    50          
147 8         32 push @$messages_notok, $condition->{message};
148 8 50       59 print STDERR "Access already blocked by " . __PACKAGE__ . "\n"
149             if $DEBUG;
150             }
151              
152             # the object in memcached is a list of timestamps, and nothing else.
153             elsif ( reftype $record eq 'ARRAY' ) {
154 51 50       128 print STDERR "Current timestamps in \$record: " . join( '|', @$record ) . "\n"
155              
156             if $DEBUG;
157 51 50       113 print STDERR "Current frozen time: $frozen_time" . "\n"
158             if $DEBUG;
159              
160             # cleanup the records (remove expired timestamps). This is
161             # where it all happens, giving us this "magic sliding time
162             # window".
163 51         197 @$record = grep { $_ > $frozen_time } @$record;
  151         383  
164 51 50       122 print STDERR "Currently unexpired timestamps in \$record: "
165             . join( '|', @$record ) . "\n"
166              
167             if $DEBUG;
168              
169             # Since we are about to add a record, if we already have the
170             # max number of records, set to blocked. If no lockout time
171             # specified, use the bucket algorithm: deny access, but do not
172             # update the record. The expired timestamps will be evicted in
173             # due time (next access, possibly), giving us more tokens.
174 51 50       109 print STDERR "Maximum is "
175             . $condition->{max}
176             . " and current number of timestamps is "
177             . @$record . "\n"
178              
179             if $DEBUG;
180 51 100       136 if ( @$record >= $condition->{max} ) {
181 11 50       36 print STDERR "Maximum reached" . "\n"
182             if $DEBUG;
183 11 50       33 if ($lockout) {
184 11 50       31 print STDERR "Setting a timed lock" . "\n"
185             if $DEBUG;
186 11         41 $cur_memcached_client->set( $memcached_key, 'block', $lockout );
187             }
188 11         188 push @$messages_notok, $condition->{message};
189             }
190              
191             # Add a timestamp to the list. This is NOT the current
192             # timestamp, but a timestamp in the future (a TTL record),
193             # which allowws for easy filtering by the grep above. And set
194             # the memcached record expiration time at the most recent TTL
195             # of the list (for automatic cleanup: the object will be
196             # discarded from memcached automatically if it is not updated
197             # before the longest TTL)
198             else {
199 40 50       85 print STDERR "Adding a timestamp to the list" . "\n"
200             if $DEBUG;
201 40         88 push @$record, $frozen_time + $condition->{ttl};
202 40         149 $cur_memcached_client->set( $memcached_key, $record, $condition->{ttl} );
203 40         597 $conditions_ok++;
204             }
205             }
206             else { # This should not happen, but catch it if it does.
207 0         0 $conditions_unknown++;
208             }
209             }
210              
211             # $record is undef, either not accessible, or not yet created
212             else {
213 15 50       57 print STDERR "No record found, creating a new one" . "\n"
214             if $DEBUG;
215 15         110 my $ret
216             = $cur_memcached_client->set( $memcached_key,
217             [ $condition->{ttl} + $frozen_time ],
218             $condition->{ttl} );
219 15         264 $conditions_ok++;
220             }
221             }
222              
223 74 50 33     196 if ( $conditions_unknown && !$QUIET ) {
224 0         0 warn "Unknown conditions count is over 0, this should not happen";
225 0         0 print STDERR "Current conditions hash: " . Dumper( \%conditions ) . "\n";
226             }
227              
228             # If logic was 'either', 1 'notok' or more should block
229             # If logic was 'all', we should have 0 'ok' to block
230             # TODO: re-work the variable names because the explanation above is a bit
231             # tricky although the logic is correct :(
232 74 50       175 if ( $condition_type eq 'either' ) {
233 0 0       0 return ( @$messages_notok > 0 )
234             ? ( SCHED_ADAPTHROTTLE_BLOCKED, $messages_notok )
235             : ( SCHED_ADAPTHROTTLE_AUTHORIZED, undef );
236             }
237             else { # condition is 'all'
238 74 100       625 return ( $conditions_ok == 0 )
239             ? ( SCHED_ADAPTHROTTLE_BLOCKED, $messages_notok )
240             : ( SCHED_ADAPTHROTTLE_AUTHORIZED, undef );
241             }
242             }
243              
244             1;
245              
246             __END__