class Rufus::Scheduler::CronLine

A 'cron line' is a line in the sense of a crontab (man 5 crontab) file line.

Constants

DAY_S
NEXT_TIME_MAX_YEARS

The max number of years in the future or the past before giving up searching for next_time or previous_time respectively

RANGE_REGEX
WEEKDAYS

Attributes

cache[R]
days[R]
hours[R]
minutes[R]
months[R]
original[R]

The string used for creating this cronline instance.

original_timezone[R]
seconds[R]
timezone[R]
weekdays[R]

attr_reader :monthdays # reader defined below

Public Class Methods

new(line) click to toggle source
# File lib/rufus/scheduler/cronline.rb, line 30
def initialize(line)

  fail ArgumentError.new(
    "not a string: #{line.inspect}"
  ) unless line.is_a?(String)

  @original = line
  @original_timezone = nil

  items = line.split

  if @timezone = EoTime.get_tzone(items.last)
    @original_timezone = items.pop
  else
    @timezone = EoTime.local_tzone
  end

  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?(Integer) }
  end

  if @days && @days.include?(0) # gh-221

    fail ArgumentError.new('invalid day 0 in cronline')
  end
end

Public Instance Methods

brute_frequency() click to toggle source

Returns the shortest delta between two potential occurrences of the schedule described by this cronline.

.

For a simple cronline like “*/5 * * * *”, obviously the frequency is five minutes. Why does this method look at a whole year of next_time ?

Consider “* * * * sun#2,sun#3”, the computed frequency is 1 week (the shortest delta is the one between the second sunday and the third sunday). This method takes no chance and runs #next_time for the span of a whole year and keeps the shortest.

Of course, this method can get VERY slow if you call on it a second- based cronline…

# File lib/rufus/scheduler/cronline.rb, line 253
    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

#st = Time.now
        t1 = next_time(t0)
#p Time.now - st
        d = t1 - t0
        delta = d if d < delta
        break if @months.nil? && t1.month == 2
        break if @months.nil? && @days.nil? && t1.day == 2
        break if @months.nil? && @days.nil? && @hours.nil? && t1.hour == 1
        break if @months.nil? && @days.nil? && @hours.nil? && @minutes.nil? && t1.min == 1
        break if t1.year >= 2001

        t0 = t1
      end

      self.class.cache[key] = delta
    end
frequency() click to toggle source

Returns a quickly computed approximation of the frequency for this cron line.

brute_frequency, on the other hand, will compute the frequency by examining a whole year, that can take more than seconds for a seconds level cron…

# File lib/rufus/scheduler/cronline.rb, line 221
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
matches?(time) click to toggle source

Returns true if the given time matches this cron line.

# File lib/rufus/scheduler/cronline.rb, line 75
def matches?(time)

    # FIXME Don't create a new EoTime if time is already a EoTime in same
    #       zone ...
    #       Wait, this seems only used in specs...
  t = EoTime.new(time.to_f, @timezone)

  return false unless sub_match?(t, :sec, @seconds)
  return false unless sub_match?(t, :min, @minutes)
  return false unless sub_match?(t, :hour, @hours)
  return false unless date_match?(t)
  true
end
next_second(time) click to toggle source
# File lib/rufus/scheduler/cronline.rb, line 286
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
next_time(from=EoTime.now) click to toggle source

Returns the next time that this cron line is supposed to 'fire'

This is raw, 3 secs to iterate over 1 year on my macbook :( brutal. (Well, I was wrong, takes 0.001 sec on 1.8.7 and 1.9.1)

This method accepts an optional Time parameter. It's the starting point for the 'search'. By default, it's Time.now

Note that the time instance returned will be in the same time zone that the given start point Time (thus a result in the local time zone will be passed if no start time is specified (search start time set to Time.now))

Rufus::Scheduler::CronLine.new('30 7 * * *').next_time(
  Time.mktime(2008, 10, 24, 7, 29))
#=> Fri Oct 24 07:30:00 -0500 2008

Rufus::Scheduler::CronLine.new('30 7 * * *').next_time(
  Time.utc(2008, 10, 24, 7, 29))
#=> Fri Oct 24 07:30:00 UTC 2008

Rufus::Scheduler::CronLine.new('30 7 * * *').next_time(
  Time.utc(2008, 10, 24, 7, 29)).localtime
#=> Fri Oct 24 02:30:00 -0500 2008

(Thanks to K Liu for the note and the examples)

# File lib/rufus/scheduler/cronline.rb, line 116
def next_time(from=EoTime.now)

  nt = nil
  zt = EoTime.new(from.to_i + 1, @timezone)
  maxy = from.year + NEXT_TIME_MAX_YEARS

  loop do

    nt = zt.dup

    fail RangeError.new(
      "failed to reach occurrence within " +
      "#{NEXT_TIME_MAX_YEARS} years for '#{original}'"
    ) if nt.year > maxy

    unless date_match?(nt)
      zt.add((24 - nt.hour) * 3600 - nt.min * 60 - nt.sec)
      next
    end
    unless sub_match?(nt, :hour, @hours)
      zt.add((60 - nt.min) * 60 - nt.sec)
      next
    end
    unless sub_match?(nt, :min, @minutes)
      zt.add(60 - nt.sec)
      next
    end
    unless sub_match?(nt, :sec, @seconds)
      zt.add(next_second(nt))
      next
    end

    break
  end

  nt
end
prev_second(time) click to toggle source
# File lib/rufus/scheduler/cronline.rb, line 297
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
previous_time(from=EoTime.now) click to toggle source

Returns the previous time the cronline matched. It's like #next_time, but for the past.

# File lib/rufus/scheduler/cronline.rb, line 157
def previous_time(from=EoTime.now)

  pt = nil
  zt = EoTime.new(from.to_i - 1, @timezone)
  miny = from.year - NEXT_TIME_MAX_YEARS

  loop do

    pt = zt.dup

    fail RangeError.new(
      "failed to reach occurrence within " +
      "#{NEXT_TIME_MAX_YEARS} years for '#{original}'"
    ) if pt.year < miny

    unless date_match?(pt)
      zt.subtract(pt.hour * 3600 + pt.min * 60 + pt.sec + 1)
      next
    end
    unless sub_match?(pt, :hour, @hours)
      zt.subtract(pt.min * 60 + pt.sec + 1)
      next
    end
    unless sub_match?(pt, :min, @minutes)
      zt.subtract(pt.sec + 1)
      next
    end
    unless sub_match?(pt, :sec, @seconds)
      zt.subtract(prev_second(pt))
      next
    end

    break
  end

  pt
end
to_a() click to toggle source

Returns an array of 6 arrays (seconds, minutes, hours, days, months, weekdays). This method is mostly used by the cronline specs.

# File lib/rufus/scheduler/cronline.rb, line 199
def to_a

  [
    toa(@seconds),
    toa(@minutes),
    toa(@hours),
    toa(@days),
    toa(@months),
    toa(@weekdays),
    toa(@monthdays),
    @timezone.name
  ]
end
Also aliased as: to_array
to_array()
Alias for: to_a

Protected Instance Methods

date_match?(zt) click to toggle source

def monthday_match?(zt, values)

return true if values.nil?

today_values = monthdays(zt)

(today_values & values).any?

end

# File lib/rufus/scheduler/cronline.rb, line 481
def date_match?(zt)

  return false unless sub_match?(zt, :day, @days)
  return false unless sub_match?(zt, :month, @months)

  return true if (
    (@weekdays && @monthdays) &&
    (sub_match?(zt, :wday, @weekdays) ||
     sub_match?(zt, :monthdays, @monthdays)))

  return false unless sub_match?(zt, :wday, @weekdays)
  return false unless sub_match?(zt, :monthdays, @monthdays)

  true
end
parse_item(item, min, max) click to toggle source
# File lib/rufus/scheduler/cronline.rb, line 370
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
parse_range(item, min, max) click to toggle source
# File lib/rufus/scheduler/cronline.rb, line 387
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
parse_weekdays(item) click to toggle source
# File lib/rufus/scheduler/cronline.rb, line 328
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
sc_sort(a) click to toggle source
# File lib/rufus/scheduler/cronline.rb, line 310
def sc_sort(a)

  a.sort_by { |e| e.is_a?(String) ? 61 : e.to_i }
end
sub_match?(time, accessor, values) click to toggle source

FIXME: Eventually split into day_match?, hour_match? and monthdays_match?o

# File lib/rufus/scheduler/cronline.rb, line 445
def sub_match?(time, accessor, values)

  return true if values.nil?

  value = time.send(accessor)

  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

  if accessor == :monthdays

    return true if (values & value).any?
  end

  values.include?(value)
end
toa(item) click to toggle source
# File lib/rufus/scheduler/cronline.rb, line 316
def toa(item)
  item == nil ? nil : item.to_a
end