Module: ClockworkWeb

Defined in:
lib/clockwork_web.rb,
lib/clockwork_web/engine.rb,
lib/clockwork_web/version.rb,
app/helpers/clockwork_web/home_helper.rb,
app/controllers/clockwork_web/home_controller.rb

Defined Under Namespace

Modules: HomeHelper Classes: Engine, HomeController

Constant Summary collapse

LAST_RUNS_KEY =
"clockwork:last_runs"
DISABLED_KEY =
"clockwork:disabled"
HEARTBEAT_KEY =
"clockwork:heartbeat"
STATUS_KEY =
"clockwork:status"
HEALTH_CHECK_KEY =
"clockwork:health_check"
VERSION =
"1.0.0"

Class Attribute Summary collapse

Class Method Summary collapse

Class Attribute Details

.clock_pathObject

Returns the value of attribute clock_path.



17
18
19
# File 'lib/clockwork_web.rb', line 17

def clock_path
  @clock_path
end

.monitorObject

Returns the value of attribute monitor.



19
20
21
# File 'lib/clockwork_web.rb', line 19

def monitor
  @monitor
end

.on_health_checkObject

Returns the value of attribute on_health_check.



24
25
26
# File 'lib/clockwork_web.rb', line 24

def on_health_check
  @on_health_check
end

.on_job_updateObject

Returns the value of attribute on_job_update.



21
22
23
# File 'lib/clockwork_web.rb', line 21

def on_job_update
  @on_job_update
end

.redisObject

Returns the value of attribute redis.



18
19
20
# File 'lib/clockwork_web.rb', line 18

def redis
  @redis
end

.running_thresholdObject

Returns the value of attribute running_threshold.



20
21
22
# File 'lib/clockwork_web.rb', line 20

def running_threshold
  @running_threshold
end

.user_methodObject

Returns the value of attribute user_method.



22
23
24
# File 'lib/clockwork_web.rb', line 22

def user_method
  @user_method
end

.warning_thresholdObject

Returns the value of attribute warning_threshold.



23
24
25
# File 'lib/clockwork_web.rb', line 23

def warning_threshold
  @warning_threshold
end

Class Method Details

.disable(job) ⇒ Object



40
41
42
43
44
45
46
47
# File 'lib/clockwork_web.rb', line 40

def self.disable(job)
  if redis
    redis.sadd(DISABLED_KEY, job)
    true
  else
    false
  end
end

.disabled_jobsObject



57
58
59
60
61
62
63
# File 'lib/clockwork_web.rb', line 57

def self.disabled_jobs
  if redis
    Set.new(redis.smembers(DISABLED_KEY))
  else
    Set.new
  end
end

.enable(job) ⇒ Object



31
32
33
34
35
36
37
38
# File 'lib/clockwork_web.rb', line 31

def self.enable(job)
  if redis
    redis.srem(DISABLED_KEY, job)
    true
  else
    false
  end
end

.enabled?(job) ⇒ Boolean

Returns:

  • (Boolean)


49
50
51
52
53
54
55
# File 'lib/clockwork_web.rb', line 49

def self.enabled?(job)
  if redis
    !redis.sismember(DISABLED_KEY, job)
  else
    true
  end
end

.health_checkObject

Runs at most once per hour across processes. When triggered, gathers overdue jobs and invokes the configured on_health_check callback if any are found.



110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
# File 'lib/clockwork_web.rb', line 110

def self.health_check
  return unless on_health_check

  now = Time.now.utc.to_i
  proceed = false

  if redis
    last = redis.get(HEALTH_CHECK_KEY).to_i
    if last == 0 || (now - last) >= 3600
      prev = redis.getset(HEALTH_CHECK_KEY, now).to_i
      proceed = (prev == last) || (now - prev) >= 3600
    end
  else
    @last_health_check ||= 0
    if (now - @last_health_check) >= 3600
      @last_health_check = now
      proceed = true
    end
  end

  return unless proceed

  events = Clockwork.manager.events
  last_runs = ClockworkWeb.last_runs
  overdue_jobs = ClockworkWeb.overdue_details(events, last_runs)
  ClockworkWeb.on_health_check.call(overdue_jobs: overdue_jobs) if overdue_jobs.any?
end

.heartbeatObject



88
89
90
91
92
93
94
95
96
97
98
# File 'lib/clockwork_web.rb', line 88

def self.heartbeat
  if redis
    heartbeat = Time.now.utc.to_i
    if heartbeat % 10 == 0 # every 10 seconds
      prev_heartbeat = redis.getset(HEARTBEAT_KEY, heartbeat).to_i
      if prev_heartbeat >= heartbeat
        redis.setex(STATUS_KEY, 60, "multiple")
      end
    end
  end
end

.last_heartbeatObject



79
80
81
82
83
84
85
86
# File 'lib/clockwork_web.rb', line 79

def self.last_heartbeat
  if redis
    timestamp = redis.get(HEARTBEAT_KEY)
    if timestamp
      Time.at(timestamp.to_i)
    end
  end
end

.last_runsObject



65
66
67
68
69
70
71
# File 'lib/clockwork_web.rb', line 65

def self.last_runs
  if redis
    Hash[redis.hgetall(LAST_RUNS_KEY).map { |job, timestamp| [job, Time.at(timestamp.to_i)] }.sort_by { |job, time| [time, job] }]
  else
    {}
  end
end

.multiple?Boolean

Returns:

  • (Boolean)


104
105
106
# File 'lib/clockwork_web.rb', line 104

def self.multiple?
  redis && redis.get(STATUS_KEY) == "multiple"
end

.now_in_event_timezone(event, base_now = Time.now.utc) ⇒ Object

Returns the last time this event should have run before now. For @at schedules, computes the most recent scheduled time at the declared hour/minute, respecting common periods (daily, multi-day, hourly). For simple periodic jobs (no @at), returns last_run + period when that is in the past. Returns nil when it cannot be determined. Convert a given time to the event timezone if supported; default to UTC.



143
144
145
146
147
148
149
# File 'lib/clockwork_web.rb', line 143

def self.now_in_event_timezone(event, base_now = Time.now.utc)
  if event.respond_to?(:convert_timezone)
    event.convert_timezone(base_now)
  else
    base_now
  end
end

.overdue?(event, last_run_time, now = Time.now.utc) ⇒ Boolean

Determines whether an event is overdue given its schedule and last run.

Returns:

  • (Boolean)


219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
# File 'lib/clockwork_web.rb', line 219

def self.overdue?(event, last_run_time, now = Time.now.utc)
  period = event.instance_variable_get(:@period) || 0
  at_time = should_have_run_at(event, last_run_time, now)
  now_for_event = now_in_event_timezone(event, now)

  # If an if-lambda is present and evaluates false at current event-local time,
  # do not consider the job overdue.
  if_lambda = event.instance_variable_get(:@if)
  if if_lambda
    begin
      allowed = if if_lambda.arity == 1
        if_lambda.call(now_for_event)
      else
        if_lambda.call
      end
      return false unless allowed
    rescue StandardError
      return true
    end
  end

  if event.instance_variable_get(:@at)
    return false unless at_time
    # Overdue if the scheduled time has passed by more than the threshold and we haven't run since
    return (now_for_event - at_time) > warning_threshold && (last_run_time.nil? || last_run_time < at_time)
  else
    return false unless last_run_time && period.positive?
    return now_for_event > (last_run_time + period + warning_threshold)
  end
end

.overdue_details(events, last_runs, now = Time.now) ⇒ Object

Collect details about overdue events for alerting or diagnostics.



251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
# File 'lib/clockwork_web.rb', line 251

def self.overdue_details(events, last_runs, now = Time.now)
  events.filter_map do |event|
    next unless ClockworkWeb.enabled?(event.job)
    lr = last_runs[event.job]
    if overdue?(event, lr, now)
      should_at = should_have_run_at(event, lr, now)
      {
        job: event.job,
        should_have_run_at: should_at,
        last_run: lr,
        period: event.instance_variable_get(:@period),
        at: event.instance_variable_get(:@at) && {
          hour: event.instance_variable_get(:@at).instance_variable_get(:@hour),
          min: event.instance_variable_get(:@at).instance_variable_get(:@min)
        }
      }
    end
  end
end

.running?Boolean

Returns:

  • (Boolean)


100
101
102
# File 'lib/clockwork_web.rb', line 100

def self.running?
  last_heartbeat && last_heartbeat > Time.now.utc - running_threshold
end

.set_last_run(job) ⇒ Object



73
74
75
76
77
# File 'lib/clockwork_web.rb', line 73

def self.set_last_run(job)
  if redis
    redis.hset(LAST_RUNS_KEY, job, Time.now.utc.to_i)
  end
end

.should_have_run_at(event, last_run_time, now = Time.now.utc) ⇒ Object



151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
# File 'lib/clockwork_web.rb', line 151

def self.should_have_run_at(event, last_run_time, now = Time.now.utc)
  period = event.instance_variable_get(:@period)
  return nil unless period

  at = event.instance_variable_get(:@at)
  if at
    now_for_event = now_in_event_timezone(event, now)
    hour = at.instance_variable_get(:@hour) || 0
    min = at.instance_variable_get(:@min) || 0
    wday = at.instance_variable_get(:@wday) rescue nil

    # Weekly or multi-week schedules with specific weekday
    if !wday.nil?
      step_weeks = (period % 604_800).zero? ? [(period / 604_800).to_i, 1].max : 1
      days_ago = (now_for_event.wday - wday) % 7
      day = now_for_event.to_date - days_ago
      candidate = Time.new(day.year, day.month, day.day, hour, min, 0, now_for_event.utc_offset)
      candidate -= 604_800 if candidate > now
      if step_weeks > 1
        anchor = last_run_time || candidate
        while (((anchor.to_date - candidate.to_date).to_i / 7) % step_weeks) != 0
          candidate -= 604_800
        end
      end
      return candidate
    end

    # Daily or multi-day schedules
    if (period % 86_400).zero?
      step_days = [(period / 86_400).to_i, 1].max
      base_day = now_for_event.to_date
      # Try the most recent aligned day within one full cycle
      0.upto(step_days - 1) do |offset|
        day = base_day - offset
        candidate = Time.new(day.year, day.month, day.day, hour, min, 0, now_for_event.utc_offset)
        if candidate <= now_for_event
          # Alignment: only consider days separated by the step length
          return candidate if (base_day - day).to_i % step_days == 0
        end
      end
      # Fallback to previous aligned cycle
      day = base_day - step_days
      return Time.new(day.year, day.month, day.day, hour, min, 0, now_for_event.utc_offset)
    end

    # Hourly or multi-hour schedules (e.g., every 2 hours at minute 15)
    if (period % 3600).zero?
      step_hours = [(period / 3600).to_i, 1].max
      aligned_hour = (now_for_event.hour / step_hours) * step_hours
      candidate = Time.new(now_for_event.year, now_for_event.month, now_for_event.day, aligned_hour, min, 0, now_for_event.utc_offset)
      candidate -= step_hours * 3600 if candidate > now
      return candidate
    end

    # Fallback: treat as daily at the given time
    candidate = Time.new(now_for_event.year, now_for_event.month, now_for_event.day, hour, min, 0, now_for_event.utc_offset)
    candidate -= 86_400 if candidate > now_for_event
    return candidate
  else
    # Simple periodic job (no @at) – use last_run anchor
    return nil unless last_run_time
    expected = last_run_time + period
    return expected if expected <= (now || Time.now.utc)
    return nil
  end
end