rufus-scheduler 1.0.12 → 1.0.13

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,1077 @@
1
+ #
2
+ #--
3
+ # Copyright (c) 2006-2009, John Mettraux, jmettraux@gmail.com
4
+ #
5
+ # Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ # of this software and associated documentation files (the "Software"), to deal
7
+ # in the Software without restriction, including without limitation the rights
8
+ # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ # copies of the Software, and to permit persons to whom the Software is
10
+ # furnished to do so, subject to the following conditions:
11
+ #
12
+ # The above copyright notice and this permission notice shall be included in
13
+ # all copies or substantial portions of the Software.
14
+ #
15
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ # THE SOFTWARE.
22
+ #++
23
+ #
24
+
25
+ #
26
+ # "made in Japan"
27
+ #
28
+ # John Mettraux at openwfe.org
29
+ #
30
+
31
+ require 'thread'
32
+ require 'rufus/scheduler/otime'
33
+ require 'rufus/scheduler/jobs'
34
+ require 'rufus/scheduler/cronline'
35
+
36
+
37
+ module Rufus
38
+
39
+ #
40
+ # The Scheduler is used by OpenWFEru for registering 'at' and 'cron' jobs.
41
+ # 'at' jobs to execute once at a given point in time. 'cron' jobs
42
+ # execute a specified intervals.
43
+ # The two main methods are thus schedule_at() and schedule().
44
+ #
45
+ # schedule_at() and schedule() await either a Schedulable instance and
46
+ # params (usually an array or nil), either a block, which is more in the
47
+ # Ruby way.
48
+ #
49
+ # == The gem "rufus-scheduler"
50
+ #
51
+ # This scheduler was previously known as the "openwferu-scheduler" gem.
52
+ #
53
+ # To ensure that code tapping the previous gem still runs fine with
54
+ # "rufus-scheduler", this new gem has 'pointers' for the old class
55
+ # names.
56
+ #
57
+ # require 'rubygems'
58
+ # require 'openwfe/util/scheduler'
59
+ # s = OpenWFE::Scheduler.new
60
+ #
61
+ # will still run OK with "rufus-scheduler".
62
+ #
63
+ # == Examples
64
+ #
65
+ # require 'rubygems'
66
+ # require 'rufus/scheduler'
67
+ #
68
+ # scheduler = Rufus::Scheduler.start_new
69
+ #
70
+ # scheduler.schedule_in("3d") do
71
+ # regenerate_monthly_report()
72
+ # end
73
+ # #
74
+ # # will call the regenerate_monthly_report method
75
+ # # in 3 days from now
76
+ #
77
+ # scheduler.schedule "0 22 * * 1-5" do
78
+ # log.info "activating security system..."
79
+ # activate_security_system()
80
+ # end
81
+ #
82
+ # job_id = scheduler.schedule_at "Sun Oct 07 14:24:01 +0900 2009" do
83
+ # init_self_destruction_sequence()
84
+ # end
85
+ #
86
+ # scheduler.join # join the scheduler (prevents exiting)
87
+ #
88
+ #
89
+ # an example that uses a Schedulable class :
90
+ #
91
+ # class Regenerator < Schedulable
92
+ # def trigger (frequency)
93
+ # self.send(frequency)
94
+ # end
95
+ # def monthly
96
+ # # ...
97
+ # end
98
+ # def yearly
99
+ # # ...
100
+ # end
101
+ # end
102
+ #
103
+ # regenerator = Regenerator.new
104
+ #
105
+ # scheduler.schedule_in("4d", regenerator)
106
+ # #
107
+ # # will regenerate the report in four days
108
+ #
109
+ # scheduler.schedule_in(
110
+ # "5d",
111
+ # { :schedulable => regenerator, :scope => :month })
112
+ # #
113
+ # # will regenerate the monthly report in 5 days
114
+ #
115
+ # There is also schedule_every() :
116
+ #
117
+ # scheduler.schedule_every("1h20m") do
118
+ # regenerate_latest_report()
119
+ # end
120
+ #
121
+ # (note : a schedule every isn't triggered immediately, thus this example
122
+ # will first trigger 1 hour and 20 minutes after being scheduled)
123
+ #
124
+ # The scheduler has a "exit_when_no_more_jobs" attribute. When set to
125
+ # 'true', the scheduler will exit as soon as there are no more jobs to
126
+ # run.
127
+ # Use with care though, if you create a scheduler, set this attribute
128
+ # to true and start the scheduler, the scheduler will immediately exit.
129
+ # This attribute is best used indirectly : the method
130
+ # join_until_no_more_jobs() wraps it.
131
+ #
132
+ # The :scheduler_precision can be set when instantiating the scheduler.
133
+ #
134
+ # scheduler = Rufus::Scheduler.new(:scheduler_precision => 0.500)
135
+ # scheduler.start
136
+ # #
137
+ # # instatiates a scheduler that checks its jobs twice per second
138
+ # # (the default is 4 times per second (0.250))
139
+ #
140
+ # Note that rufus-scheduler places a constraint on the values for the
141
+ # precision : 0.0 < p <= 1.0
142
+ # Thus
143
+ #
144
+ # scheduler.precision = 4.0
145
+ #
146
+ # or
147
+ #
148
+ # scheduler = Rufus::Scheduler.new :scheduler_precision => 5.0
149
+ #
150
+ # will raise an exception.
151
+ #
152
+ #
153
+ # == Tags
154
+ #
155
+ # Tags can be attached to jobs scheduled :
156
+ #
157
+ # scheduler.schedule_in "2h", :tags => "backup" do
158
+ # init_backup_sequence()
159
+ # end
160
+ #
161
+ # scheduler.schedule "0 24 * * *", :tags => "new_day" do
162
+ # do_this_or_that()
163
+ # end
164
+ #
165
+ # jobs = find_jobs 'backup'
166
+ # jobs.each { |job| job.unschedule }
167
+ #
168
+ # Multiple tags may be attached to a single job :
169
+ #
170
+ # scheduler.schedule_in "2h", :tags => [ "backup", "important" ] do
171
+ # init_backup_sequence()
172
+ # end
173
+ #
174
+ # The vanilla case for tags assume they are String instances, but nothing
175
+ # prevents you from using anything else. The scheduler has no persistence
176
+ # by itself, so no serialization issue.
177
+ #
178
+ #
179
+ # == Cron up to the second
180
+ #
181
+ # A cron schedule can be set at the second level :
182
+ #
183
+ # scheduler.schedule "7 * * * * *" do
184
+ # puts "it's now the seventh second of the minute"
185
+ # end
186
+ #
187
+ # The rufus scheduler recognizes an optional first column for second
188
+ # scheduling. This column can, like for the other columns, specify a
189
+ # value ("7"), a list of values ("7,8,9,27") or a range ("7-12").
190
+ #
191
+ #
192
+ # == information passed to schedule blocks
193
+ #
194
+ # When calling schedule_every(), schedule_in() or schedule_at(), the block
195
+ # expects zero or 3 parameters like in
196
+ #
197
+ # scheduler.schedule_every("1h20m") do |job_id, at, params|
198
+ # puts "my job_id is #{job_id}"
199
+ # end
200
+ #
201
+ # For schedule(), zero or two parameters can get passed
202
+ #
203
+ # scheduler.schedule "7 * * * * *" do |job_id, cron_line, params|
204
+ # puts "my job_id is #{job_id}"
205
+ # end
206
+ #
207
+ # In both cases, params corresponds to the params passed to the schedule
208
+ # method (:tags, :first_at, :first_in, :dont_reschedule, ...)
209
+ #
210
+ #
211
+ # == Exceptions
212
+ #
213
+ # The rufus scheduler will output a stacktrace to the STDOUT in
214
+ # case of exception. There are two ways to change that behaviour.
215
+ #
216
+ # # 1 - providing a lwarn method to the scheduler instance :
217
+ #
218
+ # class << scheduler
219
+ # def lwarn (&block)
220
+ # puts "oops, something wrong happened : "
221
+ # puts block.call
222
+ # end
223
+ # end
224
+ #
225
+ # # or
226
+ #
227
+ # def scheduler.lwarn (&block)
228
+ # puts "oops, something wrong happened : "
229
+ # puts block.call
230
+ # end
231
+ #
232
+ # # 2 - overriding the [protected] method log_exception(e) :
233
+ #
234
+ # class << scheduler
235
+ # def log_exception (e)
236
+ # puts "something wrong happened : "+e.to_s
237
+ # end
238
+ # end
239
+ #
240
+ # # or
241
+ #
242
+ # def scheduler.log_exception (e)
243
+ # puts "something wrong happened : "+e.to_s
244
+ # end
245
+ #
246
+ # == 'Every jobs' and rescheduling
247
+ #
248
+ # Every jobs can reschedule/unschedule themselves. A reschedule example :
249
+ #
250
+ # schedule.schedule_every "5h" do |job_id, at, params|
251
+ #
252
+ # mails = $inbox.fetch_mails
253
+ # mails.each { |m| $inbox.mark_as_spam(m) if is_spam(m) }
254
+ #
255
+ # params[:every] = if mails.size > 100
256
+ # "1h" # lots of spam, check every hour
257
+ # else
258
+ # "5h" # normal schedule, every 5 hours
259
+ # end
260
+ # end
261
+ #
262
+ # Unschedule example :
263
+ #
264
+ # schedule.schedule_every "10s" do |job_id, at, params|
265
+ # #
266
+ # # polls every 10 seconds until a mail arrives
267
+ #
268
+ # $mail = $inbox.fetch_last_mail
269
+ #
270
+ # params[:dont_reschedule] = true if $mail
271
+ # end
272
+ #
273
+ # == 'Every jobs', :first_at and :first_in
274
+ #
275
+ # Since rufus-scheduler 1.0.2, the schedule_every methods recognizes two
276
+ # optional parameters, :first_at and :first_in
277
+ #
278
+ # scheduler.schedule_every "2d", :first_in => "5h" do
279
+ # # schedule something every two days, start in 5 hours...
280
+ # end
281
+ #
282
+ # scheduler.schedule_every "2d", :first_at => "5h" do
283
+ # # schedule something every two days, start in 5 hours...
284
+ # end
285
+ #
286
+ # == job.next_time()
287
+ #
288
+ # Jobs, be they at, every or cron have a next_time() method, which tells
289
+ # when the job will be fired next time (for at and in jobs, this is also the
290
+ # last time).
291
+ #
292
+ # For cron jobs, the current implementation is quite brutal. It takes three
293
+ # seconds on my 2006 macbook to reach a cron schedule 1 year away.
294
+ #
295
+ # When is the next friday 13th ?
296
+ #
297
+ # require 'rubygems'
298
+ # require 'rufus/scheduler'
299
+ #
300
+ # puts Rufus::CronLine.new("* * 13 * fri").next_time
301
+ #
302
+ #
303
+ # == :thread_name option
304
+ #
305
+ # You can specify the name of the scheduler's thread. Should make
306
+ # it easier in some debugging situations.
307
+ #
308
+ # scheduler.new :thread_name => "the crazy scheduler"
309
+ #
310
+ #
311
+ # == job.trigger_thread
312
+ #
313
+ # Since rufus-scheduler 1.0.8, you can have access to the thread of
314
+ # a job currently being triggered.
315
+ #
316
+ # job = scheduler.get_job(job_id)
317
+ # thread = job.trigger_thread
318
+ #
319
+ # This new method will return nil if the job is not currently being
320
+ # triggered. Not that in case of an every or cron job, this method
321
+ # will return the thread of the last triggered instance, thus, in case
322
+ # of overlapping executions, you only get the most recent thread.
323
+ #
324
+ #
325
+ # == specifying a :timeout for a job
326
+ #
327
+ # rufus-scheduler 1.0.12 introduces a :timeout parameter for jobs.
328
+ #
329
+ # scheduler.every "3h", :timeout => '2h30m' do
330
+ # do_that_long_job()
331
+ # end
332
+ #
333
+ # after 2 hours and half, the 'long job' will get interrupted by a
334
+ # Rufus::TimeOutError (so that you know what to catch).
335
+ #
336
+ # :timeout is applicable to all types of jobs : at, in, every, cron. It
337
+ # accepts a String value following the "Mdhms" scheme the rufus-scheduler
338
+ # uses.
339
+ #
340
+ class Scheduler
341
+
342
+ VERSION = '1.0.13'
343
+
344
+ #
345
+ # By default, the precision is 0.250, with means the scheduler
346
+ # will check for jobs to execute 4 times per second.
347
+ #
348
+ attr_reader :precision
349
+
350
+ #
351
+ # Setting the precision ( 0.0 < p <= 1.0 )
352
+ #
353
+ def precision= (f)
354
+
355
+ raise 'precision must be 0.0 < p <= 1.0' \
356
+ if f <= 0.0 or f > 1.0
357
+
358
+ @precision = f
359
+ end
360
+
361
+ #--
362
+ # Set by default at 0.00045, it's meant to minimize drift
363
+ #
364
+ #attr_accessor :correction
365
+ #++
366
+
367
+ #
368
+ # As its name implies.
369
+ #
370
+ attr_accessor :stopped
371
+
372
+
373
+ def initialize (params={})
374
+
375
+ super()
376
+
377
+ @pending_jobs = []
378
+ @cron_jobs = {}
379
+ @non_cron_jobs = {}
380
+
381
+ @schedule_queue = Queue.new
382
+ @unschedule_queue = Queue.new
383
+ #
384
+ # sync between the step() method and the [un]schedule
385
+ # methods is done via these queues, no more mutex
386
+
387
+ @scheduler_thread = nil
388
+
389
+ @precision = 0.250
390
+ # every 250ms, the scheduler wakes up (default value)
391
+ begin
392
+ self.precision = Float(params[:scheduler_precision])
393
+ rescue Exception => e
394
+ # let precision at its default value
395
+ end
396
+
397
+ @thread_name = params[:thread_name] || 'rufus scheduler'
398
+
399
+ #@correction = 0.00045
400
+
401
+ @exit_when_no_more_jobs = false
402
+ #@dont_reschedule_every = false
403
+
404
+ @last_cron_second = -1
405
+
406
+ @stopped = true
407
+ end
408
+
409
+ #
410
+ # Starts this scheduler (or restart it if it was previously stopped)
411
+ #
412
+ def start
413
+
414
+ @stopped = false
415
+
416
+ @scheduler_thread = Thread.new do
417
+
418
+ #Thread.current[:name] = @thread_name
419
+ # doesn't work with Ruby 1.9.1
420
+
421
+ #if defined?(JRUBY_VERSION)
422
+ # require 'java'
423
+ # java.lang.Thread.current_thread.name = @thread_name
424
+ #end
425
+ # not necessary anymore (JRuby 1.1.6)
426
+
427
+ loop do
428
+
429
+ break if @stopped
430
+
431
+ t0 = Time.now.to_f
432
+
433
+ step
434
+
435
+ d = Time.now.to_f - t0 # + @correction
436
+
437
+ next if d > @precision
438
+
439
+ sleep(@precision - d)
440
+ end
441
+ end
442
+
443
+ @scheduler_thread[:name] = @thread_name
444
+ end
445
+
446
+ #
447
+ # Instantiates a new Rufus::Scheduler instance, starts it and returns it
448
+ #
449
+ def self.start_new (params = {})
450
+
451
+ s = self.new(params)
452
+ s.start
453
+ s
454
+ end
455
+
456
+ #
457
+ # The scheduler is stoppable via sstop()
458
+ #
459
+ def stop
460
+
461
+ @stopped = true
462
+ end
463
+
464
+ # (for backward compatibility)
465
+ #
466
+ alias :sstart :start
467
+
468
+ # (for backward compatibility)
469
+ #
470
+ alias :sstop :stop
471
+
472
+ #
473
+ # Joins on the scheduler thread
474
+ #
475
+ def join
476
+
477
+ @scheduler_thread.join
478
+ end
479
+
480
+ #
481
+ # Like join() but takes care of setting the 'exit_when_no_more_jobs'
482
+ # attribute of this scheduler to true before joining.
483
+ # Thus the scheduler will exit (and the join terminates) as soon as
484
+ # there aren't no more 'at' (or 'every') jobs in the scheduler.
485
+ #
486
+ # Currently used only in unit tests.
487
+ #
488
+ def join_until_no_more_jobs
489
+
490
+ @exit_when_no_more_jobs = true
491
+ join
492
+ end
493
+
494
+ #
495
+ # Ensures that a duration is a expressed as a Float instance.
496
+ #
497
+ # duration_to_f("10s")
498
+ #
499
+ # will yield 10.0
500
+ #
501
+ def duration_to_f (s)
502
+
503
+ Rufus.duration_to_f(s)
504
+ end
505
+
506
+ #--
507
+ #
508
+ # The scheduling methods
509
+ #
510
+ #++
511
+
512
+ #
513
+ # Schedules a job by specifying at which time it should trigger.
514
+ # Returns the a job_id that can be used to unschedule the job.
515
+ #
516
+ # This method returns a job identifier which can be used to unschedule()
517
+ # the job.
518
+ #
519
+ # If the job is specified in the past, it will be triggered immediately
520
+ # but not scheduled.
521
+ # To avoid the triggering, the parameter :discard_past may be set to
522
+ # true :
523
+ #
524
+ # jobid = scheduler.schedule_at(yesterday, :discard_past => true) do
525
+ # puts "you'll never read this message"
526
+ # end
527
+ #
528
+ # And 'jobid' will hold a nil (not scheduled).
529
+ #
530
+ #
531
+ def schedule_at (at, params={}, &block)
532
+
533
+ do_schedule_at(
534
+ at,
535
+ prepare_params(params),
536
+ &block)
537
+ end
538
+
539
+ #
540
+ # a shortcut for schedule_at
541
+ #
542
+ alias :at :schedule_at
543
+
544
+
545
+ #
546
+ # Schedules a job by stating in how much time it should trigger.
547
+ # Returns the a job_id that can be used to unschedule the job.
548
+ #
549
+ # This method returns a job identifier which can be used to unschedule()
550
+ # the job.
551
+ #
552
+ def schedule_in (duration, params={}, &block)
553
+
554
+ do_schedule_at(
555
+ Time.new.to_f + Rufus.duration_to_f(duration),
556
+ prepare_params(params),
557
+ &block)
558
+ end
559
+
560
+ #
561
+ # a shortcut for schedule_in
562
+ #
563
+ alias :in :schedule_in
564
+
565
+ #
566
+ # Schedules a job in a loop. After an execution, it will not execute
567
+ # before the time specified in 'freq'.
568
+ #
569
+ # This method returns a job identifier which can be used to unschedule()
570
+ # the job.
571
+ #
572
+ # In case of exception in the job, it will be rescheduled. If you don't
573
+ # want the job to be rescheduled, set the parameter :try_again to false.
574
+ #
575
+ # scheduler.schedule_every "500", :try_again => false do
576
+ # do_some_prone_to_error_stuff()
577
+ # # won't get rescheduled in case of exception
578
+ # end
579
+ #
580
+ # Since rufus-scheduler 1.0.2, the params :first_at and :first_in are
581
+ # accepted.
582
+ #
583
+ # scheduler.schedule_every "2d", :first_in => "5h" do
584
+ # # schedule something every two days, start in 5 hours...
585
+ # end
586
+ #
587
+ # (without setting a :first_in (or :first_at), our example schedule would
588
+ # have had been triggered after two days).
589
+ #
590
+ def schedule_every (freq, params={}, &block)
591
+
592
+ params = prepare_params(params)
593
+ params[:every] = freq
594
+
595
+ first_at = params[:first_at]
596
+ first_in = params[:first_in]
597
+
598
+ #params[:delayed] = true if first_at or first_in
599
+
600
+ first_at = if first_at
601
+ at_to_f(first_at)
602
+ elsif first_in
603
+ Time.now.to_f + Rufus.duration_to_f(first_in)
604
+ else
605
+ Time.now.to_f + Rufus.duration_to_f(freq) # not triggering immediately
606
+ end
607
+
608
+ do_schedule_at(first_at, params, &block)
609
+ end
610
+
611
+ #
612
+ # a shortcut for schedule_every
613
+ #
614
+ alias :every :schedule_every
615
+
616
+ #
617
+ # Schedules a cron job, the 'cron_line' is a string
618
+ # following the Unix cron standard (see "man 5 crontab" in your command
619
+ # line, or http://www.google.com/search?q=man%205%20crontab).
620
+ #
621
+ # For example :
622
+ #
623
+ # scheduler.schedule("5 0 * * *", s)
624
+ # # will trigger the schedulable s every day
625
+ # # five minutes after midnight
626
+ #
627
+ # scheduler.schedule("15 14 1 * *", s)
628
+ # # will trigger s at 14:15 on the first of every month
629
+ #
630
+ # scheduler.schedule("0 22 * * 1-5") do
631
+ # puts "it's break time..."
632
+ # end
633
+ # # outputs a message every weekday at 10pm
634
+ #
635
+ # Returns the job id attributed to this 'cron job', this id can
636
+ # be used to unschedule the job.
637
+ #
638
+ # This method returns a job identifier which can be used to unschedule()
639
+ # the job.
640
+ #
641
+ def schedule (cron_line, params={}, &block)
642
+
643
+ params = prepare_params(params)
644
+
645
+ #
646
+ # is a job with the same id already scheduled ?
647
+
648
+ cron_id = params[:cron_id] || params[:job_id]
649
+
650
+ #@unschedule_queue << cron_id
651
+
652
+ #
653
+ # schedule
654
+
655
+ b = to_block(params, &block)
656
+ job = CronJob.new(self, cron_id, cron_line, params, &b)
657
+
658
+ @schedule_queue << job
659
+
660
+ job.job_id
661
+ end
662
+
663
+ #
664
+ # an alias for schedule()
665
+ #
666
+ alias :cron :schedule
667
+
668
+ #--
669
+ #
670
+ # The UNscheduling methods
671
+ #
672
+ #++
673
+
674
+ #
675
+ # Unschedules an 'at' or a 'cron' job identified by the id
676
+ # it was given at schedule time.
677
+ #
678
+ def unschedule (job_id)
679
+
680
+ @unschedule_queue << job_id
681
+ end
682
+
683
+ #
684
+ # Unschedules a cron job
685
+ #
686
+ # (deprecated : use unschedule(job_id) for all the jobs !)
687
+ #
688
+ def unschedule_cron_job (job_id)
689
+
690
+ unschedule(job_id)
691
+ end
692
+
693
+ #--
694
+ #
695
+ # 'query' methods
696
+ #
697
+ #++
698
+
699
+ #
700
+ # Returns the job corresponding to job_id, an instance of AtJob
701
+ # or CronJob will be returned.
702
+ #
703
+ def get_job (job_id)
704
+
705
+ @cron_jobs[job_id] || @non_cron_jobs[job_id]
706
+ end
707
+
708
+ #
709
+ # Finds a job (via get_job()) and then returns the wrapped
710
+ # schedulable if any.
711
+ #
712
+ def get_schedulable (job_id)
713
+
714
+ j = get_job(job_id)
715
+ j.respond_to?(:schedulable) ? j.schedulable : nil
716
+ end
717
+
718
+ #
719
+ # Returns an array of jobs that have the given tag.
720
+ #
721
+ def find_jobs (tag=nil)
722
+
723
+ jobs = @cron_jobs.values + @non_cron_jobs.values
724
+ jobs = jobs.select { |job| job.has_tag?(tag) } if tag
725
+ jobs
726
+ end
727
+
728
+ #
729
+ # Returns all the jobs in the scheduler.
730
+ #
731
+ def all_jobs
732
+
733
+ find_jobs
734
+ end
735
+
736
+ #
737
+ # Finds the jobs with the given tag and then returns an array of
738
+ # the wrapped Schedulable objects.
739
+ # Jobs that haven't a wrapped Schedulable won't be included in the
740
+ # result.
741
+ #
742
+ def find_schedulables (tag)
743
+
744
+ find_jobs(tag).find_all { |job| job.respond_to?(:schedulable) }
745
+ end
746
+
747
+ #
748
+ # Returns the number of currently pending jobs in this scheduler
749
+ # ('at' jobs and 'every' jobs).
750
+ #
751
+ def pending_job_count
752
+
753
+ @pending_jobs.size
754
+ end
755
+
756
+ #
757
+ # Returns the number of cron jobs currently active in this scheduler.
758
+ #
759
+ def cron_job_count
760
+
761
+ @cron_jobs.size
762
+ end
763
+
764
+ #
765
+ # Returns the current count of 'every' jobs scheduled.
766
+ #
767
+ def every_job_count
768
+
769
+ @non_cron_jobs.values.select { |j| j.class == EveryJob }.size
770
+ end
771
+
772
+ #
773
+ # Returns the current count of 'at' jobs scheduled (not 'every').
774
+ #
775
+ def at_job_count
776
+
777
+ @non_cron_jobs.values.select { |j| j.class == AtJob }.size
778
+ end
779
+
780
+ #
781
+ # Returns true if the given string seems to be a cron string.
782
+ #
783
+ def self.is_cron_string (s)
784
+
785
+ s.match('.+ .+ .+ .+ .+') # well...
786
+ end
787
+
788
+ private
789
+
790
+ #
791
+ # the unschedule work itself.
792
+ #
793
+ def do_unschedule (job_id)
794
+
795
+ job = get_job job_id
796
+
797
+ return (@cron_jobs.delete(job_id) != nil) if job.is_a?(CronJob)
798
+
799
+ return false unless job # not found
800
+
801
+ if job.is_a?(AtJob) # catches AtJob and EveryJob instances
802
+ @non_cron_jobs.delete(job_id)
803
+ job.params[:dont_reschedule] = true # for AtJob as well, no worries
804
+ end
805
+
806
+ for i in 0...@pending_jobs.length
807
+ if @pending_jobs[i].job_id == job_id
808
+ @pending_jobs.delete_at i
809
+ return true # asap
810
+ end
811
+ end
812
+
813
+ true
814
+ end
815
+
816
+ #
817
+ # Making sure that params is a Hash.
818
+ #
819
+ def prepare_params (params)
820
+
821
+ params.is_a?(Schedulable) ? { :schedulable => params } : params
822
+ end
823
+
824
+ #
825
+ # The core method behind schedule_at and schedule_in (and also
826
+ # schedule_every). It's protected, don't use it directly.
827
+ #
828
+ def do_schedule_at (at, params={}, &block)
829
+
830
+ job = params.delete(:job)
831
+
832
+ unless job
833
+
834
+ jobClass = params[:every] ? EveryJob : AtJob
835
+
836
+ b = to_block(params, &block)
837
+
838
+ job = jobClass.new(self, at_to_f(at), params[:job_id], params, &b)
839
+ end
840
+
841
+ if jobClass == AtJob && job.at < (Time.new.to_f + @precision)
842
+
843
+ job.trigger() unless params[:discard_past]
844
+
845
+ @non_cron_jobs.delete job.job_id # just to be sure
846
+
847
+ return nil
848
+ end
849
+
850
+ @non_cron_jobs[job.job_id] = job
851
+
852
+ @schedule_queue << job
853
+
854
+ job.job_id
855
+ end
856
+
857
+ #
858
+ # Ensures an 'at' instance is translated to a float
859
+ # (to be compared with the float coming from time.to_f)
860
+ #
861
+ def at_to_f (at)
862
+
863
+ at = Rufus::to_ruby_time(at) if at.kind_of?(String)
864
+ at = Rufus::to_gm_time(at) if at.kind_of?(DateTime)
865
+ at = at.to_f if at.kind_of?(Time)
866
+
867
+ raise "cannot schedule at : #{at.inspect}" unless at.is_a?(Float)
868
+
869
+ at
870
+ end
871
+
872
+ #
873
+ # Returns a block. If a block is passed, will return it, else,
874
+ # if a :schedulable is set in the params, will return a block
875
+ # wrapping a call to it.
876
+ #
877
+ def to_block (params, &block)
878
+
879
+ return block if block
880
+
881
+ schedulable = params.delete(:schedulable)
882
+
883
+ return nil unless schedulable
884
+
885
+ l = lambda do
886
+ schedulable.trigger(params)
887
+ end
888
+ class << l
889
+ attr_accessor :schedulable
890
+ end
891
+ l.schedulable = schedulable
892
+
893
+ l
894
+ end
895
+
896
+ #
897
+ # Pushes an 'at' job into the pending job list
898
+ #
899
+ def push_pending_job (job)
900
+
901
+ old = @pending_jobs.find { |j| j.job_id == job.job_id }
902
+ @pending_jobs.delete(old) if old
903
+ #
904
+ # override previous job with same id
905
+
906
+ if @pending_jobs.length < 1 or job.at >= @pending_jobs.last.at
907
+ @pending_jobs << job
908
+ return
909
+ end
910
+
911
+ for i in 0...@pending_jobs.length
912
+ if job.at <= @pending_jobs[i].at
913
+ @pending_jobs[i, 0] = job
914
+ return # right place found
915
+ end
916
+ end
917
+ end
918
+
919
+ #
920
+ # This is the method called each time the scheduler wakes up
921
+ # (by default 4 times per second). It's meant to quickly
922
+ # determine if there are jobs to trigger else to get back to sleep.
923
+ # 'cron' jobs get executed if necessary then 'at' jobs.
924
+ #
925
+ def step
926
+
927
+ step_unschedule
928
+ # unschedules any job in the unschedule queue before
929
+ # they have a chance to get triggered.
930
+
931
+ step_trigger
932
+ # triggers eligible jobs
933
+
934
+ step_schedule
935
+ # schedule new jobs
936
+
937
+ # done.
938
+ end
939
+
940
+ #
941
+ # unschedules jobs in the unschedule_queue
942
+ #
943
+ def step_unschedule
944
+
945
+ loop do
946
+
947
+ break if @unschedule_queue.empty?
948
+
949
+ do_unschedule(@unschedule_queue.pop)
950
+ end
951
+ end
952
+
953
+ #
954
+ # adds every job waiting in the @schedule_queue to
955
+ # either @pending_jobs or @cron_jobs.
956
+ #
957
+ def step_schedule
958
+
959
+ loop do
960
+
961
+ break if @schedule_queue.empty?
962
+
963
+ j = @schedule_queue.pop
964
+
965
+ if j.is_a?(CronJob)
966
+
967
+ @cron_jobs[j.job_id] = j
968
+
969
+ else # it's an 'at' job
970
+
971
+ push_pending_job j
972
+ end
973
+ end
974
+ end
975
+
976
+ #
977
+ # triggers every eligible pending (at or every) jobs, then every eligible
978
+ # cron jobs.
979
+ #
980
+ def step_trigger
981
+
982
+ now = Time.now
983
+
984
+ if @exit_when_no_more_jobs && @pending_jobs.size < 1
985
+
986
+ @stopped = true
987
+ return
988
+ end
989
+
990
+ # TODO : eventually consider running cron / pending
991
+ # job triggering in two different threads
992
+ #
993
+ # but well... there's the synchronization issue...
994
+
995
+ #
996
+ # cron jobs
997
+
998
+ if now.sec != @last_cron_second
999
+
1000
+ @last_cron_second = now.sec
1001
+
1002
+ @cron_jobs.each do |cron_id, cron_job|
1003
+ #trigger(cron_job) if cron_job.matches?(now, @precision)
1004
+ cron_job.trigger if cron_job.matches?(now)
1005
+ end
1006
+ end
1007
+
1008
+ #
1009
+ # pending jobs
1010
+
1011
+ now = now.to_f
1012
+ #
1013
+ # that's what at jobs do understand
1014
+
1015
+ loop do
1016
+
1017
+ break if @pending_jobs.length < 1
1018
+
1019
+ job = @pending_jobs[0]
1020
+
1021
+ break if job.at > now
1022
+
1023
+ #if job.at <= now
1024
+ #
1025
+ # obviously
1026
+
1027
+ job.trigger
1028
+
1029
+ @pending_jobs.delete_at 0
1030
+ end
1031
+ end
1032
+
1033
+ #
1034
+ # If an error occurs in the job, it well get caught and an error
1035
+ # message will be displayed to STDOUT.
1036
+ # If this scheduler provides a lwarn(message) method, it will
1037
+ # be used insted.
1038
+ #
1039
+ # Of course, one can override this method.
1040
+ #
1041
+ def log_exception (e)
1042
+
1043
+ message =
1044
+ "trigger() caught exception\n" +
1045
+ e.to_s + "\n" +
1046
+ e.backtrace.join("\n")
1047
+
1048
+ if self.respond_to?(:lwarn)
1049
+ lwarn { message }
1050
+ else
1051
+ puts message
1052
+ end
1053
+ end
1054
+ end
1055
+
1056
+ #
1057
+ # This module adds a trigger method to any class that includes it.
1058
+ # The default implementation feature here triggers an exception.
1059
+ #
1060
+ module Schedulable
1061
+
1062
+ def trigger (params)
1063
+ raise "trigger() implementation is missing"
1064
+ end
1065
+
1066
+ def reschedule (scheduler)
1067
+ raise "reschedule() implentation is missing"
1068
+ end
1069
+ end
1070
+
1071
+ #
1072
+ # This error is thrown when the :timeout attribute triggers
1073
+ #
1074
+ class TimeOutError < RuntimeError
1075
+ end
1076
+ end
1077
+