Scheduler, more tests, almost feature-complete, todo: YAML
[catagits/Catalyst-Plugin-Scheduler.git] / lib / Catalyst / Plugin / Scheduler.pm
1 package Catalyst::Plugin::Scheduler;
2
3 use strict;
4 use warnings;
5 use base qw/Class::Accessor::Fast Class::Data::Inheritable/;
6 use DateTime;
7 use DateTime::Event::Cron;
8 use DateTime::TimeZone;
9 use NEXT;
10 use Set::Object;
11 use Storable qw/lock_store lock_retrieve/;
12 use YAML;
13
14 our $VERSION = '0.01';
15
16 __PACKAGE__->mk_classdata( '_events' => [] );
17 __PACKAGE__->mk_accessors( '_event_state' );
18
19 sub schedule {
20     my ( $class, %args ) = @_;
21     
22     unless ( $args{event} ) {
23         Catalyst::Exception->throw( 
24             message => 'The schedule method requires an event parameter'
25         );
26     }
27     
28     my $conf = $class->config->{scheduler};
29     
30     my $event = {
31         trigger  => $args{trigger},
32         event    => $args{event},
33         auto_run => ( defined $args{auto_run} ) ? $args{auto_run} : 1,
34     };
35     
36     if ( $args{at} ) {
37         # replace keywords that Set::Crontab doesn't support
38         $args{at} = _prepare_cron( $args{at} );
39         
40         # parse the cron entry into a DateTime::Set
41         my $set;
42         eval { $set = DateTime::Event::Cron->from_cron( $args{at} ) };
43         if ( $@ ) {
44             Catalyst::Exception->throw( 
45                 "Scheduler: Unable to parse 'at' value " 
46                 . $args{at} . ': ' . $@ 
47             );            
48         }
49         else {
50             $event->{set} = $set;
51         }
52     }
53     
54     push @{ $class->_events }, $event;
55 }
56
57 sub dispatch {
58     my $c = shift;
59     
60     $c->NEXT::dispatch(@_);
61     
62     $c->_get_event_state();
63     
64     # check if a minute has passed since our last check
65     # This check is not run if the user is manually triggering an event
66     if ( time - $c->_event_state->{last_check} < 60 ) {
67         return unless $c->req->params->{schedule_trigger};
68     }
69     my $last_check = $c->_event_state->{last_check};
70     $c->_event_state->{last_check} = time;
71     $c->_save_event_state();
72     
73     my $conf = $c->config->{scheduler};
74     my $last_check_dt = DateTime->from_epoch(
75         epoch     => $last_check,
76         time_zone => $conf->{time_zone}
77     );
78     my $now = DateTime->now( time_zone => $conf->{time_zone} );
79     
80     EVENT:
81     for my $event ( @{ $c->_events } ) {
82         my $next_run;
83         
84         if ( $event->{trigger} 
85           && $event->{trigger} eq $c->req->params->{schedule_trigger}
86         ) {
87             # manual trigger, run it now
88             next EVENT unless $c->_event_authorized;
89             $next_run = $now;
90         }
91         else {
92             next EVENT unless $event->{set};
93             $next_run = $event->{set}->next( $last_check_dt );
94         }
95         
96         if ( $next_run <= $now ) {
97             
98             # do some security checking for non-auto-run events
99             if ( !$event->{auto_run} ) {
100                 next EVENT unless $c->_event_authorized;
101             }
102             
103             # make sure we're the only process running this event
104             next EVENT unless $c->_mark_running( $event );
105             
106             my $event_name = $event->{trigger} || $event->{event};
107             $c->log->debug( "Scheduler: Executing $event_name" )
108                 if $c->config->{scheduler}->{logging};
109             
110             # trap errors
111             local $c->{error} = [];
112             
113             # run event
114             eval {
115                 # do not allow the event to modify the response
116                 local $c->res->{body};
117                 local $c->res->{cookies};
118                 local $c->res->{headers};
119                 local $c->res->{location};
120                 local $c->res->{status};
121                 
122                 if ( ref $event->{event} eq 'CODE' ) {
123                     $event->{event}->( $c );
124                 }
125                 else {
126                     $c->forward( $event->{event} );
127                 }
128             };
129             my @errors = @{ $c->{error} };
130             push @errors, $@ if $@;
131             if ( @errors ) {
132                 $c->log->error( 'Scheduler: Error executing ' 
133                     . "$event_name: $_" ) for @errors;
134             }
135             
136             $c->_mark_finished( $event );            
137         }
138     }
139 }
140
141 sub setup {
142     my $c = shift;
143     
144     # initial configuration
145     $c->config->{scheduler}->{logging}     ||= ( $c->debug ) ? 1 : 0;
146     $c->config->{scheduler}->{time_zone}   ||= $c->_detect_timezone();    
147     $c->config->{scheduler}->{state_file}  ||= $c->path_to('scheduler.state');
148     $c->config->{scheduler}->{hosts_allow} ||= '127.0.0.1';
149     
150     $c->NEXT::setup(@_);
151 }
152
153 # Detect the current time zone
154 sub _detect_timezone {
155     my $c = shift;
156     
157     my $tz;
158     eval { $tz = DateTime::TimeZone->new( name => 'local' ) };
159     if ($@) {
160         $c->log->warn( 
161             'Scheduler: Unable to autodetect local time zone, using UTC' );
162         return 'UTC';
163     }
164     else {
165         $c->log->debug(
166             'Scheduler: Using autodetected time zone: ' . $tz->name
167         ) if $c->config->{scheduler}->{logging};
168         return $tz->name;
169     }
170 }
171
172 # Check for authorized users on non-auto events
173 sub _event_authorized {
174     my $c = shift;
175     
176     # this should never happen, but just in case...
177     return unless $c->req->address; 
178     
179     my $hosts_allow = $c->config->{scheduler}->{hosts_allow};
180     $hosts_allow = [ $hosts_allow ] unless ref( $hosts_allow ) eq 'ARRAY';
181         
182     my $ip      = Set::Object->new( $c->req->address );
183     my $allowed = Set::Object->new( @{ $hosts_allow } );
184     
185     return $ip->subset( $allowed );
186 }
187
188 # get the state from the state file
189 sub _get_event_state {
190     my $c = shift;
191     
192     if ( -e $c->config->{scheduler}->{state_file} ) {
193         $c->_event_state( 
194             lock_retrieve $c->config->{scheduler}->{state_file} 
195         );
196     }
197     else {
198         # initialize the state file
199         $c->_event_state( { last_check => time } );
200         $c->_save_event_state();
201     }
202 }
203
204 # Check the state file to ensure we are the only process running an event
205 sub _mark_running {
206     my ( $c, $event ) = @_;
207     
208     $c->_get_event_state();
209     
210     return if $c->_event_state->{ $event->{event} };
211     
212     # this is a 2-step process to prevent race conditions
213     # 1. write the state file with our PID
214     $c->_event_state->{ $event->{event} } = $$;
215     $c->_save_event_state();
216     
217     # 2. re-read the state file and make sure it's got the same PID
218     $c->_get_event_state();
219     if ( $c->_event_state->{ $event->{event} } == $$ ) {
220         return 1;
221     }
222     
223     return;
224 }
225
226 # Mark an event as finished
227 sub _mark_finished {
228     my ( $c, $event ) = @_;
229     
230     $c->_event_state->{ $event->{event} } = 0;
231     $c->_save_event_state();
232 }
233
234 # update the state file on disk
235 sub _save_event_state {
236     my $c = shift;
237     
238     lock_store $c->_event_state, $c->config->{scheduler}->{state_file};
239 }
240
241 # Set::Crontab does not support day names, or '@' shortcuts
242 sub _prepare_cron {
243     my $cron = shift;
244     
245     return $cron unless $cron =~ /\w/;
246     
247     my %replace = (
248         jan   => 1,
249         feb   => 2,
250         mar   => 3,
251         apr   => 4,
252         may   => 5,
253         jun   => 6,
254         jul   => 7,
255         aug   => 8,
256         sep   => 9,
257         'oct' => 10,
258         nov   => 11,
259         dec   => 12,
260         
261         sun => 0,
262         mon => 1,
263         tue => 2,
264         wed => 3,
265         thu => 4,
266         fri => 5,
267         sat => 6,
268         
269         'yearly'   => '0 0 1 1 *',
270         'annually' => '0 0 1 1 *',
271         'monthly'  => '0 0 1 * *',
272         'weekly'   => '0 0 * * 0',
273         'daily'    => '0 0 * * *',
274         'midnight' => '0 0 * * *',
275         'hourly'   => '0 * * * *',
276     );
277     
278     for my $name ( keys %replace ) {
279         my $value = $replace{$name};
280         
281         if ( $cron =~ /^\@$name/ ) {
282             $cron = $value;
283             last;
284         }
285         else {
286             $cron =~ s/$name/$value/i;
287             last unless $cron =~ /\w/;
288         }
289     }
290
291     return $cron;
292 }
293
294 1;
295 __END__
296
297 =pod
298
299 =head1 NAME
300
301 Catalyst::Plugin::Scheduler - Schedule events to run in a cron-like fashion
302
303 =head1 SYNOPSIS
304
305     use Catalyst qw/Scheduler/;
306     
307     # run remove_sessions in the Cron controller every hour
308     __PACKAGE__->schedule(
309         at    => '0 * * * *',
310         event => '/cron/remove_sessions'
311     );
312     
313     # Run a subroutine at 4:05am every Sunday
314     __PACKAGE__->schedule(
315         at    => '5 4 * * sun',
316         event => \&do_stuff,
317     );
318     
319     # Define a scheduled event that must be triggered manually
320     
321 =head1 DESCRIPTION
322
323 This plugin allows you to schedule events to run at recurring intervals.
324 Events will run during the first request which meets or exceeds the specified
325 time.  Depending on the level of traffic to the application, events may or may
326 not run at exactly the correct time, but it should be enough to satisfy many
327 basic scheduling needs.
328
329 =head1 CONFIGURATION
330
331 Configuration is optional and is specified in MyApp->config->{scheduler}.
332
333 =head2 logging
334
335 Set to 1 to enable logging of events as they are executed.  This option is
336 enabled by default when running under -Debug mode.  Errors are always logged
337 regardless of the value of this option.
338
339 =head2 time_zone
340
341 The time zone of your system.  This will be autodetected where possible, or
342 will default to UTC (GMT).  You can override the detection by providing a
343 valid L<DateTime> time zone string, such as 'America/New_York'.
344
345 =head2 state_file
346
347 The current state of every event is stored in a file.  By default this is
348 $APP_HOME/scheduler.state.  This file is created on the first request if it
349 does not already exist.
350
351 =head2 hosts_allow
352
353 This option specifies IP addresses for trusted users.  This option defaults
354 to 127.0.0.1.  Multiple addresses can be specified by using an array
355 reference.  This option is used for both events where auto_run is set to 0
356 and for manually-triggered events.
357
358     __PACKAGE__->config->{scheduler}->{hosts_allow} = '192.168.1.1';
359     __PACKAGE__->config->{scheduler}->{hosts_allow} = [ 
360         '127.0.0.1',
361         '192.168.1.1'
362     ];
363
364 =head1 SCHEDULING
365
366 =head2 AUTOMATED EVENTS
367
368 Events are scheduled by calling the class method C<schedule>.
369     
370     MyApp->schedule(
371         at       => '0 * * * *',
372         event    => '/cron/remove_sessions',
373     );
374     
375     package MyApp::Controller::Cron;
376     
377     sub remove_sessions : Private {
378         my ( $self, $c ) = @_;
379         
380         $c->delete_expired_sessions;
381     }
382
383 =head3 at
384
385 The time to run an event is specified using L<crontab(5)>-style syntax.
386
387     5 0 * * *      # 5 minutes after midnight, every day
388     15 14 1 * *    # run at 2:15pm on the first of every month
389     0 22 * * 1-5   # run at 10 pm on weekdays
390     5 4 * * sun    # run at 4:05am every Sunday
391
392 From crontab(5):
393
394     field          allowed values
395     -----          --------------
396     minute         0-59
397     hour           0-23
398     day of month   1-31
399     month          0-12 (or names, see below)
400     day of week    0-7 (0 or 7 is Sun, or use names)
401     
402 Instead of the first five fields, one of seven special strings may appear:
403
404     string         meaning
405     ------         -------
406     @yearly        Run once a year, "0 0 1 1 *".
407     @annually      (same as @yearly)
408     @monthly       Run once a month, "0 0 1 * *".
409     @weekly        Run once a week, "0 0 * * 0".
410     @daily         Run once a day, "0 0 * * *".
411     @midnight      (same as @daily)
412     @hourly        Run once an hour, "0 * * * *".
413
414 =head3 event
415
416 The event to run at the specified time can be either a Catalyst private
417 action path or a coderef.  Both types of event methods will receive the $c
418 object from the current request, but you must not rely on any request-specific
419 information present in $c as it will be from a random user request at or near
420 the event's specified run time.
421
422 Important: Methods used for events should be marked C<Private> so that
423 they can not be executed via the browser.
424
425 =head3 auto_run
426
427 The auto_run parameter specifies when the event is allowed to be executed.
428 By default this option is set to 1, so the event will be executed during the
429 first request that matches the specified time in C<at>.
430
431 If set to 0, the event will only run when a request is made by a user from
432 an authorized address.  The purpose of this option is to allow long-running
433 tasks to execute only for certain users.
434
435     MyApp->schedule(
436         at       => '0 0 * * *',
437         event    => '/cron/rebuild_search_index',
438         auto_run => 0,
439     );
440     
441     package MyApp::Controller::Cron;
442     
443     sub rebuild_search_index : Private {
444         my ( $self, $c ) = @_;
445         
446         # rebuild the search index, this may take a long time
447     }
448     
449 Now, the search index will only be rebuilt when a request is made from a user
450 whose IP address matches the list in the C<hosts_allow> config option.  To
451 run this event, you probably want to ping the app from a cron job.
452
453     0 0 * * * wget -q http://www.myapp.com/
454
455 =head2 MANUAL EVENTS
456
457 To create an event that does not run on a set schedule and must be manually
458 triggered, you can specify the C<trigger> option instead of C<at>.
459
460     __PACKAGE__->schedule(
461         trigger => 'send_email',
462         event   => '/events/send_email',
463     );
464     
465 The event may then be triggered by a standard web request from an authorized
466 user.  The trigger to run is specified by using a special GET parameter,
467 'schedule_trigger'; the path requested does not matter.
468
469     http://www.myapp.com/?schedule_trigger=send_email
470     
471 By default, manual events may only be triggered by requests made from
472 localhost (127.0.0.1).  To allow other addresses to run events, use the
473 configuration option C<hosts_allow>.
474     
475 =head1 SECURITY
476
477 All events are run inside of an eval container.  This protects the user from
478 receiving any error messages or page crashes if an event fails to run
479 properly.  All event errors are logged, even if logging is disabled.
480
481 =head1 PLUGIN SUPPORT
482
483 Other plugins may register scheduled events if they need to perform periodic
484 maintenance.  Plugin authors, B<be sure to inform your users> if you do this!
485 Events should be registered from a plugin's C<setup> method.
486
487     sub setup {
488         my $c = shift;
489         $c->NEXT::setup(@_);
490         
491         if ( $c->can('schedule') ) {
492             $c->schedule(
493                 at    => '0 * * * *',
494                 event => \&cleanup,
495             );
496         }
497     }
498     
499 =head1 CAVEATS
500
501 The time at which an event will run is determined completely by the requests
502 made to the application.  Apps with heavy traffic may have events run at very
503 close to the correct time, whereas apps with low levels of traffic may see
504 events running much later than scheduled.  If this is a problem, you can use
505 a real cron entry that simply hits your application at the desired time.
506
507     0 * * * * wget -q http://www.myapp.com/
508
509 Events which consume a lot of time will slow the request processing for the
510 user who triggers the event.  For these types of events, you should use
511 auto_run => 0 or manual event triggering.
512
513 =head1 PERFORMANCE
514
515 The plugin only checks once per minute if any events need to be run, so the
516 overhead on each request is minimal.  On my test server, the difference
517 between running with Scheduler and without was only around 0.02% (0.004
518 seconds).
519
520 When a scheduled event runs, performance will depend on the code being run in
521 the event.
522
523 =head1 TODO
524
525 Support storing all scheduled events in an external YAML file.  This would
526 only allow private actions, not coderefs.  It would also allow changes to
527 the schedule to take effect in realtime.
528     
529 =head1 SEE ALSO
530
531 L<crontab(5)>
532
533 =head1 AUTHOR
534
535 Andy Grundman, <andy@hybridized.org>
536
537 =head1 COPYRIGHT
538
539 This program is free software, you can redistribute it and/or modify it
540 under the same terms as Perl itself.
541
542 =cut
543
544 .171 - with scheduler
545 .168 - without