File Coverage

blib/lib/Mojolicious/Plugin/Cron.pm
Criterion Covered Total %
statement 64 64 100.0
branch 16 20 80.0
condition 14 19 73.6
subroutine 12 12 100.0
pod 1 1 100.0
total 107 116 92.2


line stmt bran cond sub pod time code
1             package Mojolicious::Plugin::Cron;
2 3     3   106878 use Mojo::Base 'Mojolicious::Plugin';
  3         198704  
  3         25  
3 3     3   1448 use File::Spec;
  3         7  
  3         79  
4 3     3   16 use Fcntl ':flock';
  3         6  
  3         423  
5 3     3   502 use Mojo::File 'path';
  3         31716  
  3         164  
6 3     3   547 use Mojo::IOLoop;
  3         134384  
  3         30  
7 3     3   665 use Algorithm::Cron;
  3         4929  
  3         107  
8              
9 3     3   19 use Carp 'croak';
  3         9  
  3         230  
10              
11             our $VERSION = "0.032";
12 3     3   25 use constant CRON_DIR => 'mojo_cron_';
  3         7  
  3         2919  
13             my $crondir;
14              
15             sub register {
16 2     2 1 96 my ($self, $app, $cronhashes) = @_;
17 2 50       9 croak "No schedules found" unless ref $cronhashes eq 'HASH';
18              
19             # for *nix systems, getpwuid takes precedence
20             # for win systems or wherever getpwuid is not implemented,
21             # eval returns undef so getlogin takes precedence
22             $crondir
23             = path($app->config->{cron}{dir} // File::Spec->tmpdir)
24 2   33     23 ->child(CRON_DIR . (eval { scalar getpwuid($<) } || getlogin || 'nobody'),
      50        
25             $app->mode);
26             Mojo::IOLoop->next_tick(sub {
27 2 100   2   11552 if (ref((values %$cronhashes)[0]) eq 'CODE') {
28              
29             # special case, plugin => 'mm hh dd ...' => sub {}
30 1         11 $self->_cron($app->moniker,
31             {crontab => (keys %$cronhashes)[0], code => (values %$cronhashes)[0]});
32             }
33             else {
34 1         5 $self->_cron($_, $cronhashes->{$_}) for keys %$cronhashes;
35             }
36 2         197 });
37             }
38              
39             sub _cron {
40 7     7   355 my ($self, $sckey, $cronhash) = @_;
41 7         18 my $code = delete $cronhash->{code};
42 7   100     29 my $all_proc = delete $cronhash->{all_proc} // '';
43             my $test_key
44 7         41 = delete $cronhash->{__test_key}; # __test_key is for test case only
45 7   66     29 $sckey = $test_key // $sckey;
46              
47 7   100     56 $cronhash->{base} //= 'local';
48              
49 7 50       35 ref $cronhash->{crontab} eq ''
50             or croak "crontab parameter for schedule $sckey not a string";
51 7 50       20 ref $code eq 'CODE' or croak "code parameter for schedule $sckey is not CODE";
52              
53 7         45 my $cron = Algorithm::Cron->new(%$cronhash);
54 7         1151 my $time = time;
55              
56             # $all_proc, $code, $cron, $sckey and $time will be part of the $task clojure
57 7         37 my $task;
58             $task = sub {
59 28     28   102 $time = $cron->next_time($time);
60 28 100       8320 if (!$all_proc) {
61             }
62             Mojo::IOLoop->timer(
63             ($time - time) => sub {
64 21         39567 my $fire;
65 21 100       50 if ($all_proc) {
66 2         4 $fire = 1;
67             }
68             else {
69 19         103 my $dat = $crondir->child("$sckey.time");
70 19         498 my $sem = $crondir->child("$sckey.time.lock");
71 19         333 $crondir->make_path; # ensure path exists
72 19 50       1439 my $handle_sem = $sem->open('>')
73             or croak "Cannot open semaphore file $!";
74 19         2677 flock($handle_sem, LOCK_EX);
75 19 100 66     134 my $rtime = $1
      100        
76             if (-e $dat && $dat->slurp // '') =~ /(\d+)/; # do some untainting
77 19   100     1902 $rtime //= '0';
78 19 100       62 if ($rtime != $time) {
79 18         74 $dat->spurt($time);
80 18         2913 $fire = 1;
81             }
82 19         52 undef $dat;
83 19         248 undef $sem; # unlock
84             }
85 21 100       172 $code->($time) if $fire;
86 21         1003 $task->();
87             }
88 28         75 );
89 7         44 };
90 7         17 $task->();
91             }
92              
93             1;
94              
95             =encoding utf8
96              
97             =head1 NAME
98              
99             Mojolicious::Plugin::Cron - a Cron-like helper for Mojolicious and Mojolicious::Lite projects
100              
101             =head1 SYNOPSIS
102              
103             # Execute some job every 5 minutes, from 9 to 5 (4:55 actually)
104              
105             # Mojolicious::Lite
106              
107             plugin Cron => ( '*/5 9-16 * * *' => sub {
108             my $target_epoch = shift;
109             # do something non-blocking but useful
110             });
111              
112             # Mojolicious
113              
114             $self->plugin(Cron => '*/5 9-16 * * *' => sub {
115             # same here
116             });
117              
118             # More than one schedule, or more options requires extended syntax
119              
120             plugin Cron => (
121             sched1 => {
122             base => 'utc', # not needed for local time
123             crontab => '*/10 15 * * *', # at every 10th minute past hour 15 (3:00 pm to 3:50 pm)
124             code => sub {
125             # job 1 here
126             }
127             },
128             sched2 => {
129             crontab => '*/15 15 * * *', # at every 15th minute past hour 15 (3:00 pm to 3:45 pm)
130             code => sub {
131             # job 2 here
132             }
133             });
134              
135             =head1 DESCRIPTION
136              
137             L is a L plugin that allows to schedule tasks
138             directly from inside a Mojolicious application.
139              
140             The plugin mimics *nix "crontab" format to schedule tasks (see L) .
141              
142             As an extension to regular cron, seconds are supported in the form of a sixth space
143             separated field (For more information on cron syntax please see L).
144              
145             The plugin can help in development and testing phases, as it is very easy to configure and
146             doesn't require a schedule utility with proper permissions at operating system level.
147              
148             For testing, it may be helpful to use Test::Mock::Time ability to "fast-forward"
149             time calling all the timers in the interval. This way, you can actually test events programmed
150             far away in the future.
151              
152             For deployment phase, it will help avoiding the installation steps normally asociated with
153             scheduling periodic tasks.
154              
155             =head1 BASICS
156              
157             When using preforked servers (as applications running with hypnotoad), some coordination
158             is needed so jobs are not executed several times.
159              
160             L uses standard Fcntl functions for that coordination, to assure
161             a platform-independent behavior.
162              
163             Please take a look in the examples section, for a simple Mojo Application that you can
164             run on a preforked server, try hot restarts, adding / removing workers, etc, and
165             check that scheduled jobs execute without interruptions or duplications.
166              
167             =head1 EXTENDEND SYNTAX HASH
168              
169             When using extended syntax, you can define more than one crontab line, and have access
170             to more options
171              
172             plugin Cron => {key1 => {crontab line 1}, key2 => {crontab line 2}, ...};
173              
174             =head2 Keys
175              
176             Keys are the names that identify each crontab line. They are used to form a locking
177             semaphore file to avoid multiple processes starting the same job.
178              
179             You can use the same name in different Mojolicious applications that will run
180             at the same time. This will ensure that not more that one instance of the cron job
181             will take place at a specific scheduled time.
182              
183             =head2 Crontab lines
184              
185             Each crontab line consists of a hash with the following keys:
186              
187             =over 8
188            
189             =item base => STRING
190            
191             Gives the time base used for scheduling. Either C or C (default C).
192            
193             =item crontab => STRING
194            
195             Gives the crontab schedule in 5 or 6 space-separated fields.
196            
197             =item sec => STRING, min => STRING, ... mon => STRING
198            
199             Optional. Gives the schedule in a set of individual fields, if the C
200             field is not specified.
201              
202             For more information on base, crontab and other time related keys,
203             please refer to L Constructor Attributes.
204              
205             =item code => sub {...}
206              
207             Mandatory. Is the code that will be executed whenever the crontab rule fires.
208             Note that this code B be non-blocking. For tasks that are naturally
209             blocking, the recommended solution would be to enqueue tasks in a job
210             queue (like the L queue, that will play nicelly with any Mojo project).
211              
212             =back
213              
214             =head1 METHODS
215              
216             L inherits all methods from
217             L and implements the following new ones.
218              
219             =head2 register
220              
221             $plugin->register(Mojolicious->new, {Cron => '* * * * *' => sub {}});
222              
223             Register plugin in L application.
224              
225             =head1 MULTIHOST LOCKING
226              
227             The epoch corresponding to the scheduled time (i.e. the perl "time" function
228             corresponding to the current task) is available as the first parameter for the
229             callback sub. This can be used as a higher level "lock" to limit the amount
230             of simultaneous scheduled tasks to just one on a multi-host environment.
231              
232             (You will need some kind of db service accessible from all hosts).
233              
234             # Execute some job every 5 minutes, only on one of the existing hosts
235              
236             plugin Cron => ( '*/5 * * * *' => sub {
237             my $target_epoch = shift;
238             my $last_epoch = some_kind_of_atomic_swap_function(
239             key => "some id key for this crontab",
240             value => $target_epoch
241             );
242             if ($target_epoch != $last_epoc) { # Only first host will get here!
243             # do something non-blocking
244             } else {
245             # following hosts will get here. Do not call the task
246             }
247             });
248              
249             That "atomic_swap" function B. As this is unlikely the
250             case because it will normally imply a remote call, you can just enqueue a job to a L queue
251             and then inside the task filter out already executed (by other host) tasks by this lock.
252             You can see a working proof of concept [here](https://github.com/dmanto/clustered-cron-example), using
253             an L db as a resilient backend to handle the atomic swap functionality.
254              
255             =head1 WINDOWS INSTALLATION
256              
257             To install in windows environments, you need to force-install module
258             Test::Mock::Time, or installation tests will fail.
259              
260             =head1 AUTHOR
261              
262             Daniel Mantovani, C
263              
264             =head1 COPYRIGHT AND LICENCE
265              
266             Copyright (C) 2018-2021, Daniel Mantovani.
267              
268             This library is free software; you may redistribute it and/or modify it under
269             the terms of the Artistic License version 2.0.
270              
271             =head1 SEE ALSO
272              
273             L, L, L, L, L
274              
275             =cut