rocketjob 2.0.0.rc3 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,492 @@
1
+ #--
2
+ # Copyright (c) 2006-2016, John Mettraux, jmettraux@gmail.com
3
+ #
4
+ # Permission is hereby granted, free of charge, to any person obtaining a copy
5
+ # of this software and associated documentation files (the "Software"), to deal
6
+ # in the Software without restriction, including without limitation the rights
7
+ # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
8
+ # copies of the Software, and to permit persons to whom the Software is
9
+ # furnished to do so, subject to the following conditions:
10
+ #
11
+ # The above copyright notice and this permission notice shall be included in
12
+ # all copies or substantial portions of the Software.
13
+ #
14
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15
+ # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16
+ # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17
+ # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18
+ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
19
+ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
20
+ # THE SOFTWARE.
21
+ #
22
+ # Made in Japan.
23
+ #++
24
+
25
+ require 'set'
26
+
27
+ module RocketJob::Plugins::Rufus
28
+
29
+ #
30
+ # A 'cron line' is a line in the sense of a crontab
31
+ # (man 5 crontab) file line.
32
+ #
33
+ class CronLine
34
+
35
+ # The string used for creating this cronline instance.
36
+ #
37
+ attr_reader :original
38
+
39
+ attr_reader :seconds
40
+ attr_reader :minutes
41
+ attr_reader :hours
42
+ attr_reader :days
43
+ attr_reader :months
44
+ #attr_reader :monthdays # reader defined below
45
+ attr_reader :weekdays
46
+ attr_reader :timezone
47
+
48
+ def initialize(line)
49
+
50
+ fail ArgumentError.new(
51
+ "not a string: #{line.inspect}"
52
+ ) unless line.is_a?(String)
53
+
54
+ @original = line
55
+
56
+ items = line.split
57
+
58
+ @timezone = items.pop if ZoTime.is_timezone?(items.last)
59
+
60
+ fail ArgumentError.new(
61
+ "not a valid cronline : '#{line}'"
62
+ ) unless items.length == 5 or items.length == 6
63
+
64
+ offset = items.length - 5
65
+
66
+ @seconds = offset == 1 ? parse_item(items[0], 0, 59) : [ 0 ]
67
+ @minutes = parse_item(items[0 + offset], 0, 59)
68
+ @hours = parse_item(items[1 + offset], 0, 24)
69
+ @days = parse_item(items[2 + offset], -30, 31)
70
+ @months = parse_item(items[3 + offset], 1, 12)
71
+ @weekdays, @monthdays = parse_weekdays(items[4 + offset])
72
+
73
+ [ @seconds, @minutes, @hours, @months ].each do |es|
74
+
75
+ fail ArgumentError.new(
76
+ "invalid cronline: '#{line}'"
77
+ ) if es && es.find { |e| ! e.is_a?(Fixnum) }
78
+ end
79
+ end
80
+
81
+ # Returns true if the given time matches this cron line.
82
+ #
83
+ def matches?(time)
84
+
85
+ time = ZoTime.new(time.to_f, @timezone || ENV['TZ']).time
86
+
87
+ return false unless sub_match?(time, :sec, @seconds)
88
+ return false unless sub_match?(time, :min, @minutes)
89
+ return false unless sub_match?(time, :hour, @hours)
90
+ return false unless date_match?(time)
91
+ true
92
+ end
93
+
94
+ # Returns the next time that this cron line is supposed to 'fire'
95
+ #
96
+ # This is raw, 3 secs to iterate over 1 year on my macbook :( brutal.
97
+ # (Well, I was wrong, takes 0.001 sec on 1.8.7 and 1.9.1)
98
+ #
99
+ # This method accepts an optional Time parameter. It's the starting point
100
+ # for the 'search'. By default, it's Time.now
101
+ #
102
+ # Note that the time instance returned will be in the same time zone that
103
+ # the given start point Time (thus a result in the local time zone will
104
+ # be passed if no start time is specified (search start time set to
105
+ # Time.now))
106
+ #
107
+ # Rufus::Scheduler::CronLine.new('30 7 * * *').next_time(
108
+ # Time.mktime(2008, 10, 24, 7, 29))
109
+ # #=> Fri Oct 24 07:30:00 -0500 2008
110
+ #
111
+ # Rufus::Scheduler::CronLine.new('30 7 * * *').next_time(
112
+ # Time.utc(2008, 10, 24, 7, 29))
113
+ # #=> Fri Oct 24 07:30:00 UTC 2008
114
+ #
115
+ # Rufus::Scheduler::CronLine.new('30 7 * * *').next_time(
116
+ # Time.utc(2008, 10, 24, 7, 29)).localtime
117
+ # #=> Fri Oct 24 02:30:00 -0500 2008
118
+ #
119
+ # (Thanks to K Liu for the note and the examples)
120
+ #
121
+ def next_time(from=Time.now)
122
+
123
+ time = nil
124
+ zotime = ZoTime.new(from.to_i + 1, @timezone || ENV['TZ'])
125
+
126
+ loop do
127
+
128
+ time = zotime.time
129
+
130
+ unless date_match?(time)
131
+ zotime.add((24 - time.hour) * 3600 - time.min * 60 - time.sec)
132
+ next
133
+ end
134
+ unless sub_match?(time, :hour, @hours)
135
+ zotime.add((60 - time.min) * 60 - time.sec)
136
+ next
137
+ end
138
+ unless sub_match?(time, :min, @minutes)
139
+ zotime.add(60 - time.sec)
140
+ next
141
+ end
142
+ unless sub_match?(time, :sec, @seconds)
143
+ zotime.add(next_second(time))
144
+ next
145
+ end
146
+
147
+ break
148
+ end
149
+
150
+ time
151
+ end
152
+
153
+ # Returns the previous time the cronline matched. It's like next_time, but
154
+ # for the past.
155
+ #
156
+ def previous_time(from=Time.now)
157
+
158
+ time = nil
159
+ zotime = ZoTime.new(from.to_i - 1, @timezone || ENV['TZ'])
160
+
161
+ loop do
162
+
163
+ time = zotime.time
164
+
165
+ unless date_match?(time)
166
+ zotime.substract(time.hour * 3600 + time.min * 60 + time.sec + 1)
167
+ next
168
+ end
169
+ unless sub_match?(time, :hour, @hours)
170
+ zotime.substract(time.min * 60 + time.sec + 1)
171
+ next
172
+ end
173
+ unless sub_match?(time, :min, @minutes)
174
+ zotime.substract(time.sec + 1)
175
+ next
176
+ end
177
+ unless sub_match?(time, :sec, @seconds)
178
+ zotime.substract(prev_second(time))
179
+ next
180
+ end
181
+
182
+ break
183
+ end
184
+
185
+ time
186
+ end
187
+
188
+ # Returns an array of 6 arrays (seconds, minutes, hours, days,
189
+ # months, weekdays).
190
+ # This method is used by the cronline unit tests.
191
+ #
192
+ def to_array
193
+
194
+ [
195
+ toa(@seconds),
196
+ toa(@minutes),
197
+ toa(@hours),
198
+ toa(@days),
199
+ toa(@months),
200
+ toa(@weekdays),
201
+ toa(@monthdays),
202
+ @timezone
203
+ ]
204
+ end
205
+
206
+ # Returns a quickly computed approximation of the frequency for this
207
+ # cron line.
208
+ #
209
+ # #brute_frequency, on the other hand, will compute the frequency by
210
+ # examining a whole year, that can take more than seconds for a seconds
211
+ # level cron...
212
+ #
213
+ def frequency
214
+
215
+ return brute_frequency unless @seconds && @seconds.length > 1
216
+
217
+ secs = toa(@seconds)
218
+
219
+ secs[1..-1].inject([ secs[0], 60 ]) { |(prev, delta), sec|
220
+ d = sec - prev
221
+ [ sec, d < delta ? d : delta ]
222
+ }[1]
223
+ end
224
+
225
+ # Caching facility. Currently only used for brute frequencies.
226
+ #
227
+ @cache = {}; class << self; attr_reader :cache; end
228
+
229
+ # Returns the shortest delta between two potential occurences of the
230
+ # schedule described by this cronline.
231
+ #
232
+ # .
233
+ #
234
+ # For a simple cronline like "*/5 * * * *", obviously the frequency is
235
+ # five minutes. Why does this method look at a whole year of #next_time ?
236
+ #
237
+ # Consider "* * * * sun#2,sun#3", the computed frequency is 1 week
238
+ # (the shortest delta is the one between the second sunday and the third
239
+ # sunday). This method takes no chance and runs next_time for the span
240
+ # of a whole year and keeps the shortest.
241
+ #
242
+ # Of course, this method can get VERY slow if you call on it a second-
243
+ # based cronline...
244
+ #
245
+ def brute_frequency
246
+
247
+ key = "brute_frequency:#{@original}"
248
+
249
+ delta = self.class.cache[key]
250
+ return delta if delta
251
+
252
+ delta = 366 * DAY_S
253
+
254
+ t0 = previous_time(Time.local(2000, 1, 1))
255
+
256
+ loop do
257
+
258
+ break if delta <= 1
259
+ break if delta <= 60 && @seconds && @seconds.size == 1
260
+
261
+ t1 = next_time(t0)
262
+ d = t1 - t0
263
+ delta = d if d < delta
264
+
265
+ break if @months == nil && t1.month == 2
266
+ break if t1.year >= 2001
267
+
268
+ t0 = t1
269
+ end
270
+
271
+ self.class.cache[key] = delta
272
+ end
273
+
274
+ def next_second(time)
275
+
276
+ secs = toa(@seconds)
277
+
278
+ return secs.first + 60 - time.sec if time.sec > secs.last
279
+
280
+ secs.shift while secs.first < time.sec
281
+
282
+ secs.first - time.sec
283
+ end
284
+
285
+ def prev_second(time)
286
+
287
+ secs = toa(@seconds)
288
+
289
+ return time.sec + 60 - secs.last if time.sec < secs.first
290
+
291
+ secs.pop while time.sec < secs.last
292
+
293
+ time.sec - secs.last
294
+ end
295
+
296
+ protected
297
+
298
+ def sc_sort(a)
299
+
300
+ a.sort_by { |e| e.is_a?(String) ? 61 : e.to_i }
301
+ end
302
+
303
+ if RUBY_VERSION >= '1.9'
304
+ def toa(item)
305
+ item == nil ? nil : item.to_a
306
+ end
307
+ else
308
+ def toa(item)
309
+ item.is_a?(Set) ? sc_sort(item.to_a) : item
310
+ end
311
+ end
312
+
313
+ WEEKDAYS = %w[ sun mon tue wed thu fri sat ]
314
+ DAY_S = 24 * 3600
315
+ WEEK_S = 7 * DAY_S
316
+
317
+ def parse_weekdays(item)
318
+
319
+ return nil if item == '*'
320
+
321
+ items = item.downcase.split(',')
322
+
323
+ weekdays = nil
324
+ monthdays = nil
325
+
326
+ items.each do |it|
327
+
328
+ if m = it.match(/^(.+)#(l|-?[12345])$/)
329
+
330
+ fail ArgumentError.new(
331
+ "ranges are not supported for monthdays (#{it})"
332
+ ) if m[1].index('-')
333
+
334
+ expr = it.gsub(/#l/, '#-1')
335
+
336
+ (monthdays ||= []) << expr
337
+
338
+ else
339
+
340
+ expr = it.dup
341
+ WEEKDAYS.each_with_index { |a, i| expr.gsub!(/#{a}/, i.to_s) }
342
+
343
+ fail ArgumentError.new(
344
+ "invalid weekday expression (#{it})"
345
+ ) if expr !~ /^0*[0-7](-0*[0-7])?$/
346
+
347
+ its = expr.index('-') ? parse_range(expr, 0, 7) : [ Integer(expr) ]
348
+ its = its.collect { |i| i == 7 ? 0 : i }
349
+
350
+ (weekdays ||= []).concat(its)
351
+ end
352
+ end
353
+
354
+ weekdays = weekdays.uniq.sort if weekdays
355
+
356
+ [ weekdays, monthdays ]
357
+ end
358
+
359
+ def parse_item(item, min, max)
360
+
361
+ return nil if item == '*'
362
+
363
+ r = item.split(',').map { |i| parse_range(i.strip, min, max) }.flatten
364
+
365
+ fail ArgumentError.new(
366
+ "found duplicates in #{item.inspect}"
367
+ ) if r.uniq.size < r.size
368
+
369
+ r = sc_sort(r)
370
+
371
+ Set.new(r)
372
+ end
373
+
374
+ RANGE_REGEX = /^(\*|-?\d{1,2})(?:-(-?\d{1,2}))?(?:\/(\d{1,2}))?$/
375
+
376
+ def parse_range(item, min, max)
377
+
378
+ return %w[ L ] if item == 'L'
379
+
380
+ item = '*' + item if item[0, 1] == '/'
381
+
382
+ m = item.match(RANGE_REGEX)
383
+
384
+ fail ArgumentError.new(
385
+ "cannot parse #{item.inspect}"
386
+ ) unless m
387
+
388
+ mmin = min == -30 ? 1 : min # days
389
+
390
+ sta = m[1]
391
+ sta = sta == '*' ? mmin : sta.to_i
392
+
393
+ edn = m[2]
394
+ edn = edn ? edn.to_i : sta
395
+ edn = max if m[1] == '*'
396
+
397
+ inc = m[3]
398
+ inc = inc ? inc.to_i : 1
399
+
400
+ fail ArgumentError.new(
401
+ "#{item.inspect} positive/negative ranges not allowed"
402
+ ) if (sta < 0 && edn > 0) || (sta > 0 && edn < 0)
403
+
404
+ fail ArgumentError.new(
405
+ "#{item.inspect} descending day ranges not allowed"
406
+ ) if min == -30 && sta > edn
407
+
408
+ fail ArgumentError.new(
409
+ "#{item.inspect} is not in range #{min}..#{max}"
410
+ ) if sta < min || edn > max
411
+
412
+ r = []
413
+ val = sta
414
+
415
+ loop do
416
+ v = val
417
+ v = 0 if max == 24 && v == 24 # hours
418
+ r << v
419
+ break if inc == 1 && val == edn
420
+ val += inc
421
+ break if inc > 1 && val > edn
422
+ val = min if val > max
423
+ end
424
+
425
+ r.uniq
426
+ end
427
+
428
+ def sub_match?(time, accessor, values)
429
+
430
+ value = time.send(accessor)
431
+
432
+ return true if values.nil?
433
+
434
+ if accessor == :day
435
+
436
+ values.each do |v|
437
+ return true if v == 'L' && (time + DAY_S).day == 1
438
+ return true if v.to_i < 0 && (time + (1 - v) * DAY_S).day == 1
439
+ end
440
+ end
441
+
442
+ if accessor == :hour
443
+
444
+ return true if value == 0 && values.include?(24)
445
+ end
446
+
447
+ values.include?(value)
448
+ end
449
+
450
+ def monthday_match?(date, values)
451
+
452
+ return true if values.nil?
453
+
454
+ today_values = monthdays(date)
455
+
456
+ (today_values & values).any?
457
+ end
458
+
459
+ def date_match?(date)
460
+
461
+ return false unless sub_match?(date, :day, @days)
462
+ return false unless sub_match?(date, :month, @months)
463
+ return false unless sub_match?(date, :wday, @weekdays)
464
+ return false unless monthday_match?(date, @monthdays)
465
+ true
466
+ end
467
+
468
+ def monthdays(date)
469
+
470
+ pos = 1
471
+ d = date.dup
472
+
473
+ loop do
474
+ d = d - WEEK_S
475
+ break if d.month != date.month
476
+ pos = pos + 1
477
+ end
478
+
479
+ neg = -1
480
+ d = date.dup
481
+
482
+ loop do
483
+ d = d + WEEK_S
484
+ break if d.month != date.month
485
+ neg = neg - 1
486
+ end
487
+
488
+ [ "#{WEEKDAYS[date.wday]}##{pos}", "#{WEEKDAYS[date.wday]}##{neg}" ]
489
+ end
490
+ end
491
+ end
492
+