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