File Coverage

blib/lib/Algorithm/Cron.pm
Criterion Covered Total %
statement 129 129 100.0
branch 75 80 93.7
condition 37 44 84.0
subroutine 13 13 100.0
pod 2 3 66.6
total 256 269 95.1


line stmt bran cond sub pod time code
1             # You may distribute under the terms of either the GNU General Public License
2             # or the Artistic License (the same terms as Perl itself)
3             #
4             # (C) Paul Evans, 2012-2014 -- leonerd@leonerd.org.uk
5              
6             package Algorithm::Cron;
7              
8 5     5   84819 use strict;
  5         10  
  5         197  
9 5     5   19 use warnings;
  5         7  
  5         373  
10              
11             our $VERSION = '0.10';
12              
13             my @FIELDS = qw( sec min hour mday mon year wday );
14             my @FIELDS_CTOR = grep { $_ ne "year" } @FIELDS;
15              
16 5     5   62 use Carp;
  5         7  
  5         378  
17 5     5   2288 use POSIX qw( mktime strftime setlocale LC_TIME );
  5         22994  
  5         30  
18 5     5   6409 use Time::timegm qw( timegm );
  5         4574  
  5         3052  
19              
20             =head1 NAME
21              
22             C - abstract implementation of the F scheduling
23             algorithm
24              
25             =head1 SYNOPSIS
26              
27             use Algorithm::Cron;
28              
29             my $cron = Algorithm::Cron->new(
30             base => 'local',
31             crontab => "*/10 9-17 * * *",
32             );
33              
34             my $time = time;
35             while(1) {
36             $time = $cron->next_time( $time );
37              
38             sleep( time - $time );
39              
40             print "Do something\n";
41             }
42              
43             =head1 DESCRIPTION
44              
45             Objects in this class implement a time scheduling algorithm such as used by
46             F. Objects are stateless once constructed, and represent a single
47             schedule as defined by a F entry. The object implements a method
48             C which returns an epoch timestamp value to indicate the next time
49             included in the crontab schedule.
50              
51             =head2 Crontabs
52              
53             The schedule is provided as a set of acceptable values for each field of the
54             broken-down time (as returned by C or C), either in a
55             single string called C or by a set of named strings, each taking the
56             name of a F field.
57              
58             my $cron = Algorithm::Cron->new(
59             base => 'local',
60             crontab => '0 9 * * mon-fri',
61             );
62              
63             Z<>
64              
65             my $cron = Algorithm::Cron->new(
66             base => 'local',
67             min => 0,
68             hour => 9,
69             wday => "mon-fri",
70             );
71              
72             A C field containing a single asterisk (C<*>), or a missing named
73             field, indicates that any value here is included in the scheduled times. To
74             restrict the schedule, a value or set of values can be provided. This should
75             consist of one or more comma-separated numbers or ranges, where a range is
76             given as the start and end points, both inclusive.
77              
78             hour => "3-6"
79             hour => "3,4,5,6"
80              
81             Ranges can also be prefixed by a value to give the increment for values in
82             that range.
83              
84             min => "*/10"
85             min => "0,10,20,30,40,50"
86              
87             The C and C fields also allow symbolic month or weekday names in
88             place of numeric values. These names are always in the C locale, regardless of
89             the system's locale settings.
90              
91             mon => "mar-sep"
92              
93             wday => "mon,wed,fri"
94              
95             Specifying C as the end of a C range, or giving the numeric value
96             of C<7> is also supported.
97              
98             wday => "fri-sun"
99             wday => "5-7"
100             # Both equivalent to: wday => "0,5,6"
101              
102             As per F behaviour, this algorithm looks for a match of the C,
103             C and C fields, and at least one of the C or C fields.
104             If both C and C are specified, a match of either will be
105             sufficient.
106              
107             As an extension, seconds may be provided either by passing six space-separated
108             fields in the C string, or as an additional C field. If not
109             provided it will default to C<0>. If six fields are provided, the first gives
110             the seconds.
111              
112             =head2 Time Base
113              
114             C supports using either UTC or the local timezone when
115             comparing against the given schedule.
116              
117             =cut
118              
119             # mday field starts at 1, others start at 0
120             my %MIN = (
121             sec => 0,
122             min => 0,
123             hour => 0,
124             mday => 1,
125             mon => 0
126             );
127              
128             # These don't have to be real maxima, as the algorithm will cope. These are
129             # just the top end of the range expansions
130             my %MAX = (
131             sec => 59,
132             min => 59,
133             hour => 23,
134             mday => 31,
135             mon => 11,
136             wday => 6,
137             );
138              
139             my %MONTHS;
140             my %WDAYS;
141             # These always want to be in LC_TIME=C
142             {
143             my $old_loc = setlocale( LC_TIME );
144             setlocale( LC_TIME, "C" );
145              
146             %MONTHS = map { lc(strftime "%b", 0,0,0, 1, $_, 70), $_ } 0 .. 11;
147              
148             # 0 = Sun. 4th Jan 1970 was a Sunday
149             %WDAYS = map { lc(strftime "%a", 0,0,0, 4+$_, 0, 70), $_ } 0 .. 6;
150              
151             setlocale( LC_TIME, $old_loc );
152             }
153              
154             sub _expand_set
155             {
156 136     136   170 my ( $spec, $kind ) = @_;
157              
158 136 100       302 return undef if $spec eq "*";
159              
160 87         81 my @vals;
161 87         182 foreach my $val ( split m/,/, $spec ) {
162 88         84 my $step = 1;
163 88         73 my $end;
164              
165 88 100       207 $val =~ s{/(\d+)$}{} and $step = $1;
166              
167 88 100       189 $val =~ m{^(.+)-(.+)$} and ( $val, $end ) = ( $1, $2 );
168 88 100       331 if( $val eq "*" ) {
    100          
    100          
169 7         53 ( $val, $end ) = ( $MIN{$kind}, $MAX{$kind} );
170             }
171             elsif( $kind eq "mon" ) {
172             # Users specify 1-12 but we want 0-11
173 10   100     137 defined and m/^\d+$/ and $_-- for $val, $end;
      66        
174             # Convert symbolics
175 10   100     97 defined and exists $MONTHS{lc $_} and $_ = $MONTHS{lc $_} for $val, $end;
      66        
176             }
177             elsif( $kind eq "wday" ) {
178             # Convert symbolics
179 11   100     110 defined and exists $WDAYS{lc $_} and $_ = $WDAYS{lc $_} for $val, $end;
      66        
180 11 100 100     60 $end = 7 if defined $end and $end == 0 and $val > 0;
      66        
181             }
182              
183 88 100       432 $val =~ m/^\d+$/ or croak "$val is unrecognised for $kind";
184 87 100 33     179 $end =~ m/^\d+$/ or croak "$end is unrecognised for $kind" if defined $end;
185              
186 87         118 push @vals, $val;
187 87   100     337 push @vals, $val while defined $end and ( $val += $step ) <= $end;
188              
189 87 100 100     335 if( $kind eq "wday" && $vals[-1] == 7 ) {
190 2 50       12 unshift @vals, 0 unless $vals[0] == 0;
191 2         4 pop @vals;
192             }
193             }
194              
195 86         287 return \@vals;
196             }
197              
198 5     5   32 use constant { EXTRACT => 0, BUILD => 1, NORMALISE => 2 };
  5         6  
  5         1003  
199             my %time_funcs = (
200             # EXTRACT BUILD NORMALISE
201             local => [ sub { localtime $_[0] }, \&mktime, sub { localtime mktime @_[0..5], -1, -1, -1 } ],
202             utc => [ sub { gmtime $_[0] }, \&timegm, sub { gmtime timegm @_[0..5], -1, -1, -1 } ],
203             );
204              
205             # Indices in time array
206             use constant {
207 5         1933 TM_SEC => 0,
208             TM_MIN => 1,
209             TM_HOUR => 2,
210             TM_MDAY => 3,
211             TM_MON => 4,
212             TM_YEAR => 5,
213             TM_WDAY => 6,
214 5     5   25 };
  5         9  
215              
216             =head1 CONSTRUCTOR
217              
218             =cut
219              
220             =head2 $cron = Algorithm::Cron->new( %args )
221              
222             Constructs a new C object representing the given schedule
223             relative to the given time base. Takes the following named arguments:
224              
225             =over 8
226              
227             =item base => STRING
228              
229             Gives the time base used for scheduling. Either C or C.
230              
231             =item crontab => STRING
232              
233             Gives the crontab schedule in 5 or 6 space-separated fields.
234              
235             =item sec => STRING, min => STRING, ... mon => STRING
236              
237             Optional. Gives the schedule in a set of individual fields, if the C
238             field is not specified.
239              
240             =back
241              
242             =cut
243              
244             sub new
245             {
246 22     22 1 18710 my $class = shift;
247 22         158 my %params = @_;
248              
249 22         58 my $base = delete $params{base};
250 22 50       49 grep { $_ eq $base } qw( local utc ) or croak "Unrecognised base - should be 'local' or 'utc'";
  44         176  
251              
252 22 100       118 if( exists $params{crontab} ) {
253 21         42 my $crontab = delete $params{crontab};
254 21         188 s/^\s+//, s/\s+$// for $crontab;
255              
256 21         90 my @fields = split m/\s+/, $crontab;
257 21 100       304 @fields >= 5 or croak "Expected at least 5 crontab fields";
258 20 50       131 @fields <= 6 or croak "Expected no more than 6 crontab fields";
259              
260 20 100       143 @fields = ( "0", @fields ) if @fields < 6;
261 20         235 @params{ @FIELDS_CTOR } = @fields;
262             }
263              
264 21 100       95 $params{sec} = 0 unless exists $params{sec};
265              
266 21         131 my $self = bless {
267             base => $base,
268             }, $class;
269              
270 21         183 foreach ( @FIELDS_CTOR ) {
271 122 100       216 next unless exists $params{$_};
272              
273 121         252 $self->{$_} = _expand_set( delete $params{$_}, $_ );
274 120 50 100     337 !defined $self->{$_} or scalar @{ $self->{$_} } or
  72         171  
275             croak "Require at least one value for '$_' field";
276             }
277              
278 20         60 return $self;
279             }
280              
281             =head1 METHODS
282              
283             =cut
284              
285             =head2 @seconds = $cron->sec
286              
287             =head2 @minutes = $cron->min
288              
289             =head2 @hours = $cron->hour
290              
291             =head2 @mdays = $cron->mday
292              
293             =head2 @months = $cron->mon
294              
295             =head2 @wdays = $cron->wday
296              
297             Accessors that return a list of the accepted values for each scheduling field.
298             These are returned in a plain list of numbers, regardless of the form they
299             were specified to the constructor.
300              
301             Also note that the list of valid months will be 0-based (in the range 0 to 11)
302             rather than 1-based, to match the values used by C, C,
303             C and C.
304              
305             =cut
306              
307             foreach my $field ( @FIELDS_CTOR ) {
308 5     5   32 no strict 'refs';
  5         6  
  5         4930  
309             *$field = sub {
310 27     27   65 my $self = shift;
311 27 100       24 @{ $self->{$field} || [] };
  27         230  
312             };
313             }
314              
315             sub next_time_field
316             {
317 978     978 0 901 my $self = shift;
318 978         1015 my ( $t, $idx ) = @_;
319              
320 978         1204 my $funcs = $time_funcs{$self->{base}};
321              
322 978 100       2562 my $spec = $self->{ $FIELDS[$idx] } or return 1;
323              
324 713         691 my $old = $t->[$idx];
325 713         599 my $new;
326              
327 713   66     2001 $_ >= $old and $new = $_, last for @$spec;
328              
329             # wday field is special. We can't alter it directly; any changes to it have
330             # to happen via mday
331 713 100       1020 if( $idx == TM_WDAY ) {
332 103         85 $idx = TM_MDAY;
333             # Adjust $new by the same delta
334 103 100       204 $new = $t->[TM_MDAY] + $new - $old if defined $new;
335 103         126 $old = $t->[TM_MDAY];
336              
337 103 100       286 if( !defined $new ) {
    100          
338             # Next week
339 16         52 $t->[$_] = $MIN{$FIELDS[$_]} for TM_SEC .. TM_HOUR;
340             # Add more days, such that we hit the next occurance of $spec->[0]
341 16         60 $t->[TM_MDAY] += $spec->[0] + 7 - $t->[TM_WDAY];
342              
343 16         27 @$t = $funcs->[NORMALISE]->( @$t );
344              
345 16         195 return 0;
346             }
347             elsif( $new > $old ) {
348 8         38 $t->[$_] = $MIN{$FIELDS[$_]} for TM_SEC .. $idx-1;
349             }
350             }
351             else {
352 610 100       1322 if( !defined $new ) {
    100          
353             # Rollover
354 164         513 $t->[$_] = $MIN{$FIELDS[$_]} for TM_SEC .. $idx-1;
355 164         252 $t->[$idx] = $spec->[0];
356 164         211 $t->[$idx+1]++;
357              
358 164         268 @$t = $funcs->[NORMALISE]->( @$t );
359              
360 164         845 return 0;
361             }
362             elsif( $new > $old ) {
363             # Next field; reset
364 70         237 $t->[$_] = $MIN{$FIELDS[$_]} for TM_SEC .. $idx-1;
365             }
366             }
367              
368 533         581 $t->[$idx] = $new;
369              
370             # Detect rollover of month and reset to next month
371 533         593 my $was_mon = $t->[TM_MON];
372              
373 533         937 @$t = $funcs->[NORMALISE]->( @$t );
374              
375 533 100 100     1609 if( $idx == TM_MDAY and $was_mon != $t->[TM_MON] ) {
376 6         16 $t->[$_] = 0 for TM_SEC .. TM_HOUR;
377 6         22 $t->[TM_MDAY] = 1;
378              
379 6         10 @$t = $funcs->[NORMALISE]->( @$t );
380              
381 6         70 return 0;
382             }
383              
384 527         1307 return 1;
385             }
386              
387             =head2 $time = $cron->next_time( $start_time )
388              
389             Returns the next scheduled time, as an epoch timestamp, after the given
390             timestamp. This is a stateless operation; it does not change any state stored
391             by the C<$cron> object.
392              
393             =cut
394              
395             sub next_time
396             {
397 74     74 1 2388 my $self = shift;
398 74         95 my ( $time ) = @_;
399              
400 74         193 my $funcs = $time_funcs{$self->{base}};
401              
402             # Always need to add at least 1 second
403 74         203 my @t = $funcs->[EXTRACT]->( $time + 1 );
404              
405 245 100       567 RESTART:
406             $self->next_time_field( \@t, TM_MON ) or goto RESTART;
407              
408 240 100 100     1080 if( defined $self->{mday} and defined $self->{wday} ) {
    100          
    100          
409             # Now it gets tricky because cron allows a match of -either- mday or wday
410             # rather than requiring both. So we'll work out which of the two is sooner
411 29         29 my $next_time_by_wday;
412 29         61 my @wday_t = @t;
413 29         30 my $wday_restart = 0;
414 29 100       55 $self->next_time_field( \@wday_t, TM_WDAY ) or $wday_restart = 1;
415 29         90 $next_time_by_wday = $funcs->[BUILD]->( @wday_t );
416              
417 29         28 my $next_time_by_mday;
418 29         57 my @mday_t = @t;
419 29         29 my $mday_restart = 0;
420 29 100       100 $self->next_time_field( \@mday_t, TM_MDAY ) or $mday_restart = 1;
421 29         78 $next_time_by_mday = $funcs->[BUILD]->( @mday_t );
422              
423 29 100       49 if( $next_time_by_wday > $next_time_by_mday ) {
424 4         11 @t = @mday_t;
425 4 50       13 goto RESTART if $mday_restart;
426             }
427             else {
428 25         62 @t = @wday_t;
429 25 100       315 goto RESTART if $wday_restart;
430             }
431             }
432             elsif( defined $self->{mday} ) {
433 84 100       165 $self->next_time_field( \@t, TM_MDAY ) or goto RESTART;
434             }
435             elsif( defined $self->{wday} ) {
436 74 100       97 $self->next_time_field( \@t, TM_WDAY ) or goto RESTART;
437             }
438              
439 215         287 foreach my $idx ( TM_HOUR, TM_MIN, TM_SEC ) {
440 517 100       800 $self->next_time_field( \@t, $idx ) or goto RESTART;
441             }
442              
443 74         498 return $funcs->[BUILD]->( @t );
444             }
445              
446             =head1 AUTHOR
447              
448             Paul Evans
449              
450             =cut
451              
452             0x55AA;