class Rufus::Scheduler::CronLine
(man 5 crontab) file line.
A ‘cron line’ is a line in the sense of a crontab
def brute_frequency
based cronline...
Of course, this method can get VERY slow if you call on it a second-
of a whole year and keeps the shortest.
sunday). This method takes no chance and runs next_time for the span
(the shortest delta is the one between the second sunday and the third
Consider "* * * * sun#2,sun#3", the computed frequency is 1 week
five minutes. Why does this method look at a whole year of #next_time ?
For a simple cronline like "*/5 * * * *", obviously the frequency is
.
schedule described by this cronline.
Returns the shortest delta between two potential occurences of the
def brute_frequency key = "brute_frequency:#{@original}" delta = self.class.cache[key] return delta if delta delta = 366 * DAY_S t0 = previous_time(Time.local(2000, 1, 1)) loop do break if delta <= 1 break if delta <= 60 && @seconds && @seconds.size == 1 t1 = next_time(t0) d = t1 - t0 delta = d if d < delta break if @months == nil && t1.month == 2 break if t1.year >= 2001 t0 = t1 end self.class.cache[key] = delta end
def date_match?(date)
def date_match?(date) return false unless sub_match?(date, :day, @days) return false unless sub_match?(date, :month, @months) return false unless sub_match?(date, :wday, @weekdays) return false unless monthday_match?(date, @monthdays) true end
def frequency
level cron...
examining a whole year, that can take more than seconds for a seconds
#brute_frequency, on the other hand, will compute the frequency by
cron line.
Returns a quickly computed approximation of the frequency for this
def frequency return brute_frequency unless @seconds && @seconds.length > 1 secs = toa(@seconds) secs[1..-1].inject([ secs[0], 60 ]) { |(prev, delta), sec| d = sec - prev [ sec, d < delta ? d : delta ] }[1] end
def initialize(line)
def initialize(line) fail ArgumentError.new( "not a string: #{line.inspect}" ) unless line.is_a?(String) @original = line items = line.split @timezone = items.pop if ZoTime.is_timezone?(items.last) fail ArgumentError.new( "not a valid cronline : '#{line}'" ) unless items.length == 5 or items.length == 6 offset = items.length - 5 @seconds = offset == 1 ? parse_item(items[0], 0, 59) : [ 0 ] @minutes = parse_item(items[0 + offset], 0, 59) @hours = parse_item(items[1 + offset], 0, 24) @days = parse_item(items[2 + offset], -30, 31) @months = parse_item(items[3 + offset], 1, 12) @weekdays, @monthdays = parse_weekdays(items[4 + offset]) [ @seconds, @minutes, @hours, @months ].each do |es| fail ArgumentError.new( "invalid cronline: '#{line}'" ) if es && es.find { |e| ! e.is_a?(Fixnum) } end end
def matches?(time)
Returns true if the given time matches this cron line.
def matches?(time) time = ZoTime.new(time.to_f, @timezone || ENV['TZ']).time return false unless sub_match?(time, :sec, @seconds) return false unless sub_match?(time, :min, @minutes) return false unless sub_match?(time, :hour, @hours) return false unless date_match?(time) true end
def monthday_match?(date, values)
def monthday_match?(date, values) return true if values.nil? today_values = monthdays(date) (today_values & values).any? end
def monthdays(date)
def monthdays(date) pos = 1 d = date.dup loop do d = d - WEEK_S break if d.month != date.month pos = pos + 1 end neg = -1 d = date.dup loop do d = d + WEEK_S break if d.month != date.month neg = neg - 1 end [ "#{date.wday}##{pos}", "#{date.wday}##{neg}" ] end
def next_second(time)
def next_second(time) secs = toa(@seconds) return secs.first + 60 - time.sec if time.sec > secs.last secs.shift while secs.first < time.sec secs.first - time.sec end
def next_time(from=Time.now)
(Thanks to K Liu for the note and the examples)
#=> Fri Oct 24 02:30:00 -0500 2008
Time.utc(2008, 10, 24, 7, 29)).localtime
Rufus::Scheduler::CronLine.new('30 7 * * *').next_time(
#=> Fri Oct 24 07:30:00 UTC 2008
Time.utc(2008, 10, 24, 7, 29))
Rufus::Scheduler::CronLine.new('30 7 * * *').next_time(
#=> Fri Oct 24 07:30:00 -0500 2008
Time.mktime(2008, 10, 24, 7, 29))
Rufus::Scheduler::CronLine.new('30 7 * * *').next_time(
Time.now))
be passed if no start time is specified (search start time set to
the given start point Time (thus a result in the local time zone will
Note that the time instance returned will be in the same time zone that
for the 'search'. By default, it's Time.now
This method accepts an optional Time parameter. It's the starting point
(Well, I was wrong, takes 0.001 sec on 1.8.7 and 1.9.1)
This is raw, 3 secs to iterate over 1 year on my macbook :( brutal.
Returns the next time that this cron line is supposed to 'fire'
def next_time(from=Time.now) time = nil zotime = ZoTime.new(from.to_i + 1, @timezone || ENV['TZ']) loop do time = zotime.time unless date_match?(time) zotime.add((24 - time.hour) * 3600 - time.min * 60 - time.sec) next end unless sub_match?(time, :hour, @hours) zotime.add((60 - time.min) * 60 - time.sec) next end unless sub_match?(time, :min, @minutes) zotime.add(60 - time.sec) next end unless sub_match?(time, :sec, @seconds) zotime.add(next_second(time)) next end break end time end
def parse_item(item, min, max)
def parse_item(item, min, max) return nil if item == '*' r = item.split(',').map { |i| parse_range(i.strip, min, max) }.flatten fail ArgumentError.new( "found duplicates in #{item.inspect}" ) if r.uniq.size < r.size r = sc_sort(r) Set.new(r) end
def parse_range(item, min, max)
def parse_range(item, min, max) return %w[ L ] if item == 'L' item = '*' + item if item[0, 1] == '/' m = item.match(RANGE_REGEX) fail ArgumentError.new( "cannot parse #{item.inspect}" ) unless m mmin = min == -30 ? 1 : min # days sta = m[1] sta = sta == '*' ? mmin : sta.to_i edn = m[2] edn = edn ? edn.to_i : sta edn = max if m[1] == '*' inc = m[3] inc = inc ? inc.to_i : 1 fail ArgumentError.new( "#{item.inspect} positive/negative ranges not allowed" ) if (sta < 0 && edn > 0) || (sta > 0 && edn < 0) fail ArgumentError.new( "#{item.inspect} descending day ranges not allowed" ) if min == -30 && sta > edn fail ArgumentError.new( "#{item.inspect} is not in range #{min}..#{max}" ) if sta < min || edn > max fail ArgumentError.new( "#{item.inspect} increment must be greater than zero" ) if inc == 0 r = [] val = sta loop do v = val v = 0 if max == 24 && v == 24 # hours r << v break if inc == 1 && val == edn val += inc break if inc > 1 && val > edn val = min if val > max end r.uniq end
def parse_weekdays(item)
def parse_weekdays(item) return nil if item == '*' weekdays = nil monthdays = nil item.downcase.split(',').each do |it| WEEKDAYS.each_with_index { |a, i| it.gsub!(/#{a}/, i.to_s) } it = it.gsub(/([^#])l/, '\1#-1') # "5L" == "5#-1" == the last Friday if m = it.match(/\A(.+)#(l|-?[12345])\z/) fail ArgumentError.new( "ranges are not supported for monthdays (#{it})" ) if m[1].index('-') it = it.gsub(/#l/, '#-1') (monthdays ||= []) << it else fail ArgumentError.new( "invalid weekday expression (#{item})" ) if it !~ /\A0*[0-7](-0*[0-7])?\z/ its = it.index('-') ? parse_range(it, 0, 7) : [ Integer(it) ] its = its.collect { |i| i == 7 ? 0 : i } (weekdays ||= []).concat(its) end end weekdays = weekdays.uniq.sort if weekdays [ weekdays, monthdays ] end
def prev_second(time)
def prev_second(time) secs = toa(@seconds) return time.sec + 60 - secs.last if time.sec < secs.first secs.pop while time.sec < secs.last time.sec - secs.last end
def previous_time(from=Time.now)
for the past.
Returns the previous time the cronline matched. It's like next_time, but
def previous_time(from=Time.now) time = nil zotime = ZoTime.new(from.to_i - 1, @timezone || ENV['TZ']) loop do time = zotime.time unless date_match?(time) zotime.substract(time.hour * 3600 + time.min * 60 + time.sec + 1) next end unless sub_match?(time, :hour, @hours) zotime.substract(time.min * 60 + time.sec + 1) next end unless sub_match?(time, :min, @minutes) zotime.substract(time.sec + 1) next end unless sub_match?(time, :sec, @seconds) zotime.substract(prev_second(time)) next end break end time end
def sc_sort(a)
def sc_sort(a) a.sort_by { |e| e.is_a?(String) ? 61 : e.to_i } end
def sub_match?(time, accessor, values)
def sub_match?(time, accessor, values) value = time.send(accessor) return true if values.nil? if accessor == :day values.each do |v| return true if v == 'L' && (time + DAY_S).day == 1 return true if v.to_i < 0 && (time + (1 - v) * DAY_S).day == 1 end end if accessor == :hour return true if value == 0 && values.include?(24) end values.include?(value) end
def to_a
This method is mostly used by the cronline specs.
months, weekdays).
Returns an array of 6 arrays (seconds, minutes, hours, days,
def to_a [ toa(@seconds), toa(@minutes), toa(@hours), toa(@days), toa(@months), toa(@weekdays), toa(@monthdays), @timezone ] end
def toa(item)
def toa(item) item == nil ? nil : item.to_a end
def toa(item)
def toa(item) item.is_a?(Set) ? sc_sort(item.to_a) : item end