File Coverage

blib/lib/Minion/Util.pm
Criterion Covered Total %
statement 85 86 98.8
branch 50 50 100.0
condition 23 27 85.1
subroutine 11 11 100.0
pod 3 3 100.0
total 172 177 97.1


line stmt bran cond sub pod time code
1             package Minion::Util;
2 3     3   93122 use Mojo::Base -strict;
  3         5  
  3         18  
3              
4 3     3   498 use Carp qw(croak);
  3         4  
  3         129  
5 3     3   12 use Exporter qw(import);
  3         3  
  3         80  
6 3     3   1450 use Time::Local qw(timegm);
  3         4921  
  3         5347  
7              
8             our @EXPORT_OK = qw(desired_tasks next_cron_time parse_cron);
9              
10             my %NICKNAMES = (
11             '@yearly' => '0 0 1 1 *',
12             '@annually' => '0 0 1 1 *',
13             '@monthly' => '0 0 1 * *',
14             '@weekly' => '0 0 * * 0',
15             '@daily' => '0 0 * * *',
16             '@midnight' => '0 0 * * *',
17             '@hourly' => '0 * * * *',
18             );
19              
20             my %MONTH_NAMES = (
21             jan => 1,
22             feb => 2,
23             mar => 3,
24             apr => 4,
25             may => 5,
26             jun => 6,
27             jul => 7,
28             aug => 8,
29             sep => 9,
30             oct => 10,
31             nov => 11,
32             dec => 12
33             );
34             my %DAY_NAMES = (sun => 0, mon => 1, tue => 2, wed => 3, thu => 4, fri => 5, sat => 6);
35              
36             my @FIELDS = (
37             ['minute', 0, 59],
38             ['hour', 0, 23],
39             ['day-of-month', 1, 31],
40             ['month', 1, 12, \%MONTH_NAMES],
41             ['day-of-week', 0, 7, \%DAY_NAMES],
42             );
43              
44             sub desired_tasks {
45 7     7 1 144046 my ($limits, $available_tasks, $active_tasks) = @_;
46              
47 7         10 my %count;
48 7         18 $count{$_}++ for @$active_tasks;
49              
50 7         11 my @desired;
51 7         9 for my $task (@$available_tasks) {
52 13   100     28 my $count = $count{$task} // 0;
53 13         12 my $limit = $limits->{$task};
54 13 100 66     36 push @desired, $task if !defined($limit) || $count < $limit;
55             }
56              
57 7         459 return \@desired;
58             }
59              
60             sub next_cron_time {
61 17     17 1 49232 my ($cron, $from) = @_;
62 17 100       56 my $parsed = ref $cron ? $cron : parse_cron($cron);
63              
64 17         52 my $t = int($from / 60) * 60 + 60;
65 17         27 my $limit = $t + 5 * 366 * 86400;
66 17         49 while ($t < $limit) {
67 100         329 my (undef, $min, $hour, $mday, $mon, $year, $wday) = gmtime $t;
68 100         155 $mon++;
69              
70             # Month
71 100 100       243 if (!$parsed->[3]{set}{$mon}) {
72 5         17 my $next = _next_value($parsed->[3]{values}, $mon);
73             $t
74             = defined $next
75             ? timegm(0, 0, 0, 1, $next - 1, $year)
76 5 100       32 : timegm(0, 0, 0, 1, $parsed->[3]{values}[0] - 1, $year + 1);
77 5         205 next;
78             }
79              
80             # Day of month / day of week (Vixie OR semantics)
81 95 100       168 if (!_day_match($parsed, $mday, $wday)) {
82 66         151 $t = timegm(0, 0, 0, $mday, $mon - 1, $year) + 86400;
83 66         2116 next;
84             }
85              
86             # Hour
87 29 100       67 if (!$parsed->[1]{set}{$hour}) {
88 8         16 my $next = _next_value($parsed->[1]{values}, $hour);
89 8 100       32 $t
90             = defined $next ? timegm(0, 0, $next, $mday, $mon - 1, $year) : timegm(0, 0, 0, $mday, $mon - 1, $year) + 86400;
91 8         186 next;
92             }
93              
94             # Minute
95 21 100       44 if (!$parsed->[0]{set}{$min}) {
96 4         10 my $next = _next_value($parsed->[0]{values}, $min);
97 4 100       38 $t
98             = defined $next
99             ? timegm(0, $next, $hour, $mday, $mon - 1, $year)
100             : timegm(0, 0, $hour, $mday, $mon - 1, $year) + 3600;
101 4         120 next;
102             }
103              
104 17         273 return $t;
105             }
106              
107 0         0 croak qq{No matching time found for cron expression};
108             }
109              
110             sub parse_cron {
111 59   50 59 1 36241 my $expr = shift // '';
112              
113 59 100       163 if ($expr =~ /^@/) {
114 11 100       222 croak qq{Unknown cron nickname "$expr"} unless exists $NICKNAMES{$expr};
115 10         17 $expr = $NICKNAMES{$expr};
116             }
117              
118 58         152 my @fields = split /\s+/, $expr;
119 58 100       277 croak qq{Invalid cron expression "$expr": expected 5 fields} unless @fields == 5;
120              
121 56         101 return [map { _parse_field($fields[$_], @{$FIELDS[$_]}) } 0 .. 4];
  254         333  
  254         459  
122             }
123              
124             sub _day_match {
125 95     95   153 my ($parsed, $mday, $wday) = @_;
126 95 100 100     246 return 1 if $parsed->[2]{is_star} && $parsed->[4]{is_star};
127 78 100       161 return $parsed->[4]{set}{$wday} if $parsed->[2]{is_star};
128 66 100       226 return $parsed->[2]{set}{$mday} if $parsed->[4]{is_star};
129 6   33     16 return $parsed->[2]{set}{$mday} || $parsed->[4]{set}{$wday};
130             }
131              
132             sub _next_value {
133 17     17   31 my ($values, $after) = @_;
134 17 100       34 for my $v (@$values) { return $v if $v >= $after }
  21         48  
135 9         19 return undef;
136             }
137              
138             sub _parse_field {
139 254     254   438 my ($field, $name, $min, $max, $names) = @_;
140              
141 254 100       407 my $is_star = $field eq '*' ? 1 : 0;
142 254         300 my %set;
143 254         479 for my $part (split /,/, $field) {
144 258         398 my ($range, $step) = split m{/}, $part, 2;
145 258   100     1303 $step //= 1;
146 258 100       835 croak qq{Invalid step "$step" in $name field} unless $step =~ /^[1-9]\d*$/;
147              
148 257         310 my ($a, $b);
149 257 100       528 if ($range eq '*') { ($a, $b) = ($min, $max) }
  143 100       221  
    100          
150 8         13 elsif ($range =~ /^(\w+)-(\w+)$/) { ($a, $b) = (_resolve($1, $name, $names), _resolve($2, $name, $names)) }
151 105         151 elsif ($range =~ /^(\w+)$/) { $a = $b = _resolve($1, $name, $names) }
152 1         92 else { croak qq{Invalid $name field "$part"} }
153 254 100 100     1537 croak qq{Value out of range in $name field "$part" ($min-$max)} if $a < $min || $b > $max || $a > $b;
      100        
154              
155 248         379 for (my $v = $a; $v <= $b; $v += $step) { $set{$v} = 1 }
  3122         5229  
156             }
157              
158             # Day-of-week 7 is an alias for Sunday (0)
159 244 100 100     503 $set{0} = 1 if $name eq 'day-of-week' && delete $set{7};
160              
161 244         1108 return {set => \%set, values => [sort { $a <=> $b } keys %set], is_star => $is_star};
  10410         10936  
162             }
163              
164             sub _resolve {
165 121     121   304 my ($value, $name, $names) = @_;
166 121 100       388 return $value + 0 if $value =~ /^\d+$/;
167 12 100 100     273 croak qq{Invalid name "$value" in $name field} unless $names && exists $names->{lc $value};
168 10         21 return $names->{lc $value};
169             }
170              
171             1;
172              
173             =encoding utf8
174              
175             =head1 NAME
176              
177             Minion::Util - Minion utility functions
178              
179             =head1 SYNOPSIS
180              
181             use Minion::Util qw(desired_tasks next_cron_time parse_cron);
182              
183             =head1 DESCRIPTION
184              
185             L provides utility functions for L.
186              
187             =head1 FUNCTIONS
188              
189             L implements the following functions, which can be imported individually.
190              
191             =head2 desired_tasks
192              
193             my $desired_tasks = desired_tasks $limits, $available_tasks, $active_tasks;
194              
195             Enforce limits and generate list of currently desired tasks.
196              
197             # ['bar']
198             desired_tasks {foo => 2}, ['foo', 'bar'], ['foo', 'foo'];
199              
200             =head2 next_cron_time
201              
202             my $epoch = next_cron_time $expr, $from;
203             my $epoch = next_cron_time $parsed, $from;
204              
205             Compute the next epoch time matching a five field cron expression, strictly after the given epoch. All times are
206             interpreted in B, not local time. Accepts either a cron expression string or a structure returned by
207             L, so the parsed form can be reused across calls without reparsing.
208              
209             # 1747051500 (next 12:05 UTC after 1747051200)
210             next_cron_time '*/5 * * * *', 1747051200;
211              
212             # Reuse the parsed structure
213             my $cron = parse_cron '0 4 * * *';
214             my $next = next_cron_time $cron, time;
215              
216             =head2 parse_cron
217              
218             my $parsed = parse_cron $expr;
219              
220             Parse a five field cron expression into a structure suitable for matching. Croaks on invalid input. The supported
221             syntax covers C<*>, C<*/N>, C, C and comma separated lists for the fields C, C,
222             C, C and C. The C field also accepts the names C-C, and the
223             C field accepts C-C and treats both C<0> and C<7> as Sunday (names are case-insensitive). When
224             both C and C are restricted, jobs run when B matches, following the standard Vixie
225             L behavior. The nicknames C<@yearly>, C<@annually>, C<@monthly>, C<@weekly>, C<@daily>, C<@midnight> and
226             C<@hourly> expand to the equivalent five field expression.
227              
228             # Every weekday at 9 in the morning
229             parse_cron '0 9 * * MON-FRI';
230              
231             # Every day at midnight
232             parse_cron '@daily';
233              
234             =head1 SEE ALSO
235              
236             L, L, L, L, L.
237              
238             =cut