Class: Hammer::Cron

Inherits:
Object
  • Object
show all
Defined in:
lib/hammer/cron.rb

Overview

Schedule of a task declared with cron '<expr>'. Parses the expression once (at Hammerfile eval, so typos fail at load time) and answers the scheduler's questions: does this wall-clock minute match, is a run due given the last one, and when is the next run.

Accepted forms:

cron '*/10 * * * *'   # standard 5-field crontab
cron '10m'            # simple interval: every 10 minutes
cron '10s' / '2h' / '1d'  # seconds / hours / days
cron '@daily'         # shortcut, expands to '0 0 * * *'

Cron fields support '', 'a', 'a-b', '/n', 'a-b/n' and comma lists, numeric values only (no 'jan'/'mon' names). Weekday 7 equals 0 (Sunday). Intervals are measured from the last run - not aligned to the clock - which makes odd periods like '90m' possible and keeps restarts from re-firing (last run persists in the state file).

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(expr) ⇒ Cron

Returns a new instance of Cron.



37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
# File 'lib/hammer/cron.rb', line 37

def initialize(expr)
  @source = expr.to_s.strip

  if (m = @source.match(/\A(\d+)([smhd])\z/))
    @interval = m[1].to_i * INTERVAL_UNITS[m[2]]
    raise Hammer::Error, "cron: interval must be > 0 in #{@source.inspect}" if @interval.zero?
    return
  end

  if @source.start_with?('@') && !SHORTCUTS.key?(@source)
    raise Hammer::Error, "cron: unknown shortcut #{@source.inspect} (valid: #{SHORTCUTS.keys.join(', ')})"
  end

  parts = (SHORTCUTS[@source] || @source).split(/\s+/)
  unless parts.size == 5
    raise Hammer::Error, "cron: #{@source.inspect} - expected 5 fields ('*/10 * * * *'), " \
                         "an interval ('10s', '10m', '2h', '1d') or a shortcut (#{SHORTCUTS.keys.join(', ')})"
  end

  @fields   = FIELDS.each_with_index.map { |(name, lo, hi), i| parse_field(name, parts[i], lo, hi) }
  @dom_star = parts[2] == '*'
  @dow_star = parts[4] == '*'
end

Instance Attribute Details

#intervalObject (readonly)

Returns the value of attribute interval.



35
36
37
# File 'lib/hammer/cron.rb', line 35

def interval
  @interval
end

#sourceObject (readonly)

Returns the value of attribute source.



35
36
37
# File 'lib/hammer/cron.rb', line 35

def source
  @source
end

Instance Method Details

#due?(tick, last_run) ⇒ Boolean

Should the scheduler fire at tick (a boundary-aligned Time), given the persisted last run? Cron mode fires once per matching minute; interval mode fires when a full interval has elapsed (or the job never ran, so a fresh job gives immediate feedback on the first tick).

Returns:

  • (Boolean)


83
84
85
86
87
88
89
# File 'lib/hammer/cron.rb', line 83

def due?(tick, last_run)
  if interval?
    last_run.nil? || tick.to_i - last_run.to_i >= @interval
  else
    matches?(tick) && (last_run.nil? || last_run.to_i / 60 < tick.to_i / 60)
  end
end

#interval?Boolean

Returns:

  • (Boolean)


61
62
63
# File 'lib/hammer/cron.rb', line 61

def interval?
  !@interval.nil?
end

#matches?(t) ⇒ Boolean

True when local wall-clock time t (minute resolution) matches the cron expression. Vixie-cron day rule: when BOTH day-of-month and day-of-week are restricted, matching either one is enough; otherwise both must match.

Returns:

  • (Boolean)


69
70
71
72
73
74
75
76
# File 'lib/hammer/cron.rb', line 69

def matches?(t)
  return false if interval?
  min, hour, dom, mon, dow = @fields
  return false unless min[t.min] && hour[t.hour] && mon[t.month]
  dom_ok = dom[t.day]
  dow_ok = dow[t.wday]
  @dom_star || @dow_star ? dom_ok && dow_ok : dom_ok || dow_ok
end

#next_run(from = Time.now, last_run: nil) ⇒ Object

Next fire time strictly after from. Cron mode walks minute by minute - real-world expressions match within days, and the walk is ~100 hash lookups per simulated minute - capped at 500 days so an impossible date (a Feb 30 style expression) fails loudly instead of spinning forever.



96
97
98
99
100
101
102
103
104
105
106
107
108
# File 'lib/hammer/cron.rb', line 96

def next_run(from = Time.now, last_run: nil)
  if interval?
    return from if last_run.nil?
    Time.at(last_run.to_i + @interval)
  else
    t = Time.at((from.to_i / 60 + 1) * 60)
    (500 * 24 * 60).times do
      return t if matches?(t)
      t += 60
    end
    raise Hammer::Error, "cron: #{@source.inspect} never matches"
  end
end