cronwatch 0.8.0 → 0.9.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.
- checksums.yaml +4 -4
- data/lib/cronwatch/client.rb +27 -4
- data/lib/cronwatch/evaluate.rb +16 -2
- data/lib/cronwatch/output.rb +10 -0
- data/lib/cronwatch/pg_cron.rb +56 -11
- data/lib/cronwatch/stores/active_record.rb +23 -10
- data/lib/cronwatch/stores/memory.rb +20 -5
- data/lib/cronwatch/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6df945edfb266ca737de6343276860554a4d3d740331091e1167d00fe93888a4
|
|
4
|
+
data.tar.gz: ec33c3140fb5f453de2cd0263b74a894b590408394f9f1aade76f1be2485e190
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ba15e440f49e390715b9668a24c0ee2b1c0cf35dac907a461555d18181fc95572b0ba3fee50b00237893733ca5b864b9f35b6a4e30b0f0bee64e6eefa1f3c5b0
|
|
7
|
+
data.tar.gz: 71e579e3ebba4a279cfef5de79d611157dcc2fc8ad176fac77cc737e7e0750c9ecd3cfefed383b2ac51f2f7aef5a5a24a24a472b6b739e90c1bd007a5814f0f5
|
data/lib/cronwatch/client.rb
CHANGED
|
@@ -326,6 +326,9 @@ module Cronwatch
|
|
|
326
326
|
input = run.is_a?(Run) ? run : Run.from_h(run.is_a?(Hash) ? run.to_h { |k, v| [Naming.camel(k), v] } : run)
|
|
327
327
|
declared = @registry.synchronize { @definitions[input.job] }
|
|
328
328
|
raise ArgumentError, "record_run: job \"#{input.job}\" is not declared; call job first" unless declared
|
|
329
|
+
if input.id.to_s.include?("\0")
|
|
330
|
+
raise ArgumentError, "record_run: run ids cannot contain a NUL character (job \"#{input.job}\")"
|
|
331
|
+
end
|
|
329
332
|
|
|
330
333
|
sync(declared)
|
|
331
334
|
run = input.dup
|
|
@@ -537,6 +540,8 @@ module Cronwatch
|
|
|
537
540
|
def reset_process_state
|
|
538
541
|
@pid = Process.pid
|
|
539
542
|
@locks = {}
|
|
543
|
+
# Each job's turn to write its declaration. See sync.
|
|
544
|
+
@syncing = {}
|
|
540
545
|
@check_lock = Mutex.new
|
|
541
546
|
@checking = nil
|
|
542
547
|
@ticker_lock = Mutex.new
|
|
@@ -642,18 +647,34 @@ module Cronwatch
|
|
|
642
647
|
end
|
|
643
648
|
end
|
|
644
649
|
|
|
650
|
+
# Writes the declaration of `definition`'s name as it stands, unless the
|
|
651
|
+
# store has it. A handle kept from an earlier declaration writes the one
|
|
652
|
+
# that replaced it, never its own over it, and one forgotten since writes
|
|
653
|
+
# its own. The writes of one name take turns (the name's lock in
|
|
654
|
+
# @syncing, held across the write), so one still under way cannot land
|
|
655
|
+
# after a later one; and a name declared again while its write was under
|
|
656
|
+
# way is still to be written.
|
|
645
657
|
def sync(definition)
|
|
646
658
|
ensure_ready
|
|
647
|
-
|
|
659
|
+
name = definition.name
|
|
660
|
+
return if @registry.synchronize { @synced.include?(name) }
|
|
648
661
|
|
|
649
|
-
|
|
650
|
-
@registry.synchronize { @
|
|
662
|
+
after_fork_check
|
|
663
|
+
turn = @registry.synchronize { @syncing[name] ||= Mutex.new }
|
|
664
|
+
turn.synchronize do
|
|
665
|
+
standing = @registry.synchronize { @synced.include?(name) ? nil : @definitions.fetch(name, definition) }
|
|
666
|
+
next if standing.nil?
|
|
667
|
+
|
|
668
|
+
@store.upsert_job(Serialize.to_stored(standing), now)
|
|
669
|
+
@registry.synchronize { @synced << name if @definitions.fetch(name, standing).equal?(standing) }
|
|
670
|
+
end
|
|
651
671
|
end
|
|
652
672
|
|
|
653
673
|
# Runs the block while holding the job's lock, so two runs (or a run and a
|
|
654
674
|
# check) in this process never read and write the job's state over each
|
|
655
675
|
# other. Other processes are coordinated by update_state instead. Only
|
|
656
|
-
# store reads and writes happen inside; alerts are sent outside it.
|
|
676
|
+
# store reads and writes happen inside; alerts are sent outside it. sync
|
|
677
|
+
# gives a job's declaration writes turns the same way, on a lock of their own.
|
|
657
678
|
def serial(job, &block)
|
|
658
679
|
after_fork_check
|
|
659
680
|
lock = @registry.synchronize { @locks[job] ||= Monitor.new }
|
|
@@ -991,6 +1012,8 @@ module Cronwatch
|
|
|
991
1012
|
got = id.is_a?(String) ? "#{JS.length16(id)} characters" : id.class.to_s
|
|
992
1013
|
raise ArgumentError, "job \"#{job}\": #{method}() needs a run id of 1 to 200 characters (got #{got})"
|
|
993
1014
|
end
|
|
1015
|
+
# Postgres refuses NUL in text, so no store could hold such an id.
|
|
1016
|
+
raise ArgumentError, "job \"#{job}\": #{method}() cannot take a run id containing a NUL character" if id.include?("\0")
|
|
994
1017
|
return unless id.start_with?(RESERVED_RUN_ID_PREFIX)
|
|
995
1018
|
|
|
996
1019
|
raise ArgumentError, "job \"#{job}\": #{method}() cannot take a run id starting with \"#{RESERVED_RUN_ID_PREFIX}\", " \
|
data/lib/cronwatch/evaluate.rb
CHANGED
|
@@ -48,6 +48,19 @@ module Cronwatch
|
|
|
48
48
|
version.to_i
|
|
49
49
|
end
|
|
50
50
|
|
|
51
|
+
# The failures in a row a stored state counts as: its `consecutive_failures`
|
|
52
|
+
# when that is a whole number, held at MAX_DURATION_MS (2**53 - 1), and 0
|
|
53
|
+
# when it is negative or not a whole number. A foreign row's count past
|
|
54
|
+
# 2**53 stays at the top, as the SDK holds it, and a 1.5, "3" or -1
|
|
55
|
+
# counts as none.
|
|
56
|
+
def failure_count(state)
|
|
57
|
+
count = state&.consecutive_failures
|
|
58
|
+
return 0 unless count.is_a?(Integer) || (count.is_a?(Float) && count.finite? && count == count.floor)
|
|
59
|
+
return 0 unless count.positive?
|
|
60
|
+
|
|
61
|
+
[count, MAX_DURATION_MS].min.to_i
|
|
62
|
+
end
|
|
63
|
+
|
|
51
64
|
def empty_state(job)
|
|
52
65
|
JobState.new(job: job, open: {}, consecutive_failures: 0, silenced_until: nil, last_alert_at: nil,
|
|
53
66
|
pending_recovery: [], undelivered: [])
|
|
@@ -61,7 +74,7 @@ module Cronwatch
|
|
|
61
74
|
JobState.new(
|
|
62
75
|
job: state.job.nil? ? job : state.job,
|
|
63
76
|
open: (state.open || {}).dup,
|
|
64
|
-
consecutive_failures: state
|
|
77
|
+
consecutive_failures: failure_count(state),
|
|
65
78
|
silenced_until: state.silenced_until,
|
|
66
79
|
last_alert_at: state.last_alert_at,
|
|
67
80
|
pending_recovery: (state.pending_recovery || []).dup,
|
|
@@ -233,7 +246,8 @@ module Cronwatch
|
|
|
233
246
|
end
|
|
234
247
|
|
|
235
248
|
# failed or timeout
|
|
236
|
-
|
|
249
|
+
# Held at the top, as the SDK holds it: a count at the limit stays there.
|
|
250
|
+
next_state.consecutive_failures = [next_state.consecutive_failures + 1, MAX_DURATION_MS].min
|
|
237
251
|
close_condition(next_state, :missed)
|
|
238
252
|
threshold = [1, definition.failures_before_alert || 1].max
|
|
239
253
|
condition = run.status == :timeout ? :stuck : :failed
|
data/lib/cronwatch/output.rb
CHANGED
|
@@ -137,6 +137,16 @@ module Cronwatch
|
|
|
137
137
|
text.include?("\0") ? text.delete("\0") : text
|
|
138
138
|
end
|
|
139
139
|
|
|
140
|
+
# Removes every U+0000 from JSON text, keys and strings alike, by dropping
|
|
141
|
+
# each \u0000 escape (a NUL can appear in JSON no other way). Escapes are
|
|
142
|
+
# read left to right in pairs, so an escaped backslash followed by "u0000"
|
|
143
|
+
# is left as it is.
|
|
144
|
+
def strip_json_nul(json)
|
|
145
|
+
return json unless json.include?("\\u0000")
|
|
146
|
+
|
|
147
|
+
json.gsub(/\\(u0000|.)/m) { Regexp.last_match(1) == "u0000" ? "" : Regexp.last_match(0) }
|
|
148
|
+
end
|
|
149
|
+
|
|
140
150
|
# "Name: message" and the first five backtrace lines, each written as
|
|
141
151
|
# " at <line>" like the frames of a JavaScript stack, capped like output.
|
|
142
152
|
# An exception that stops the thread rather than reporting a problem
|
data/lib/cronwatch/pg_cron.rb
CHANGED
|
@@ -41,8 +41,10 @@ module Cronwatch
|
|
|
41
41
|
# prefix: put before every job name, to keep them apart from your own ("db:"). Also keeps run ids apart.
|
|
42
42
|
# job_name: a callable giving the CronWatch name for a Job. Default its jobname with anything other than
|
|
43
43
|
# letters, digits, ".", "_", ":" and "-" turned into "-", or "pg_cron:<jobid>" when it has none.
|
|
44
|
-
# The prefix goes in front either way.
|
|
45
|
-
#
|
|
44
|
+
# The prefix goes in front either way. One that raises or returns no name, like a `jobs` or
|
|
45
|
+
# `options` callable that raises, is reported once and fails only that job, which keeps its last
|
|
46
|
+
# declaration until the callable works again.
|
|
47
|
+
# options: grace, timeout, max_duration, expect and the rest, for every job (a hash) or per job (a callable
|
|
46
48
|
# given a Job). The schedule and timezone always come from pg_cron.
|
|
47
49
|
# timezone: the timezone pg_cron reads its cron expressions in. Default the server's cron.timezone, read from
|
|
48
50
|
# pg_settings, which shows it only to roles with pg_read_all_settings; UTC (pg_cron's default) is
|
|
@@ -110,6 +112,8 @@ module Cronwatch
|
|
|
110
112
|
@retired = Set.new
|
|
111
113
|
@scanned = false
|
|
112
114
|
@warned = Set.new
|
|
115
|
+
# Jobids whose callback failed, reported once until it works again.
|
|
116
|
+
@failing = Set.new
|
|
113
117
|
@name = "pg_cron"
|
|
114
118
|
end
|
|
115
119
|
|
|
@@ -269,8 +273,7 @@ module Cronwatch
|
|
|
269
273
|
"scheduled: connect as that role, or give this one BYPASSRLS.")
|
|
270
274
|
end
|
|
271
275
|
all = rows.map { |r| job_from(r) }
|
|
272
|
-
|
|
273
|
-
names, definitions = declare(host, jobs, timezone, recording)
|
|
276
|
+
names, definitions = declare(host, all, timezone, recording)
|
|
274
277
|
retire_unused(host, names, definitions, all)
|
|
275
278
|
return [] if !recording || names.empty?
|
|
276
279
|
|
|
@@ -316,18 +319,60 @@ module Cronwatch
|
|
|
316
319
|
/\A\d{1,15}\z/.match?(rest) ? rest.to_i : nil
|
|
317
320
|
end
|
|
318
321
|
|
|
319
|
-
# Declares each job. A paused one (active = false) keeps its failures but loses its schedule, so it
|
|
320
|
-
# Returns [{ jobid => name }, { jobid => definition as declared }].
|
|
321
|
-
def declare(host,
|
|
322
|
+
# Declares each job picked. A paused one (active = false) keeps its failures but loses its schedule, so it
|
|
323
|
+
# is not missed. Returns [{ jobid => name }, { jobid => definition as declared }].
|
|
324
|
+
def declare(host, all, timezone, recording)
|
|
322
325
|
names = {}
|
|
323
326
|
definitions = {}
|
|
324
327
|
used = Set.new
|
|
325
|
-
jobs
|
|
326
|
-
|
|
328
|
+
# A callback of the app's (jobs, job_name, options) that raised, or a job_name that gave no name, fails
|
|
329
|
+
# only its job, as a bad row does: reported once until it works again, and the job carries on as last
|
|
330
|
+
# declared (skipped when it never was), so its runs are still copied.
|
|
331
|
+
trouble = lambda do |job, what|
|
|
332
|
+
unless @failing.include?(job.jobid)
|
|
333
|
+
@failing << job.jobid
|
|
334
|
+
host.on_error(RuntimeError.new("pg_cron job #{job.jobid}: #{what}; it keeps its last declaration until that works"),
|
|
335
|
+
"source pg_cron")
|
|
336
|
+
end
|
|
337
|
+
last = @known[job.jobid]
|
|
338
|
+
next if last.nil? || used.include?(last[:name])
|
|
339
|
+
|
|
340
|
+
names[job.jobid] = last[:name]
|
|
341
|
+
definitions[job.jobid] = last[:definition]
|
|
342
|
+
used << last[:name]
|
|
343
|
+
end
|
|
344
|
+
all.each do |job|
|
|
345
|
+
begin
|
|
346
|
+
picked = picks?(job)
|
|
347
|
+
rescue StandardError => e
|
|
348
|
+
trouble.call(job, "the jobs callback raised #{e.class}: #{e.message}")
|
|
349
|
+
next
|
|
350
|
+
end
|
|
351
|
+
unless picked
|
|
352
|
+
@failing.delete(job.jobid)
|
|
353
|
+
next
|
|
354
|
+
end
|
|
355
|
+
begin
|
|
356
|
+
base = @job_name ? @job_name.call(job) : PgCron.job_name(job)
|
|
357
|
+
rescue StandardError => e
|
|
358
|
+
trouble.call(job, "job_name raised #{e.class}: #{e.message}")
|
|
359
|
+
next
|
|
360
|
+
end
|
|
361
|
+
unless base.is_a?(String) || base.is_a?(Symbol)
|
|
362
|
+
trouble.call(job, "job_name returned #{base.nil? ? "nil" : base.class}, not a name")
|
|
363
|
+
next
|
|
364
|
+
end
|
|
365
|
+
begin
|
|
366
|
+
extra = (@options.respond_to?(:call) ? @options.call(job) : @options) || {}
|
|
367
|
+
extra = extra.to_h.transform_keys(&:to_sym).except(*SCHEDULE_ONLY)
|
|
368
|
+
rescue StandardError => e
|
|
369
|
+
trouble.call(job, "the options callback raised #{e.class}: #{e.message}")
|
|
370
|
+
next
|
|
371
|
+
end
|
|
372
|
+
@failing.delete(job.jobid)
|
|
373
|
+
name = @prefix + base.to_s
|
|
327
374
|
name = "#{name}:#{job.jobid}" if used.include?(name)
|
|
328
375
|
used << name
|
|
329
|
-
extra = (@options.respond_to?(:call) ? @options.call(job) : @options) || {}
|
|
330
|
-
extra = extra.to_h.transform_keys(&:to_sym).except(*SCHEDULE_ONLY)
|
|
331
376
|
schedule = job.active && recording ? PgCron.schedule(job.schedule) : nil
|
|
332
377
|
definition = {
|
|
333
378
|
description: "pg_cron job #{job.jobid} in #{job.database} as #{job.username}#{job.active ? "" : " (paused)"}",
|
|
@@ -180,7 +180,7 @@ module Cronwatch
|
|
|
180
180
|
|
|
181
181
|
def upsert_job(definition, now)
|
|
182
182
|
definition = JobDefinition.from_h(definition)
|
|
183
|
-
write(:upsert_job, [definition.name,
|
|
183
|
+
write(:upsert_job, [definition.name, json_text(definition.to_h), now, now])
|
|
184
184
|
nil
|
|
185
185
|
end
|
|
186
186
|
|
|
@@ -205,14 +205,14 @@ module Cronwatch
|
|
|
205
205
|
|
|
206
206
|
def insert_run(run)
|
|
207
207
|
write(:insert_run, [run.id, run.job, run.status.to_s, run.started_at, run.finished_at, run.duration_ms,
|
|
208
|
-
run.error, run.output,
|
|
208
|
+
text(run.error), text(run.output), json_text(run.metrics || {}), text(run.trigger)])
|
|
209
209
|
nil
|
|
210
210
|
end
|
|
211
211
|
|
|
212
212
|
# A run that is gone (its job was forgotten) stays gone.
|
|
213
213
|
def update_run(run)
|
|
214
|
-
write(:update_run, [run.status.to_s, run.finished_at, run.duration_ms, run.error, run.output,
|
|
215
|
-
|
|
214
|
+
write(:update_run, [run.status.to_s, run.finished_at, run.duration_ms, text(run.error), text(run.output),
|
|
215
|
+
json_text(run.metrics || {}), run.id])
|
|
216
216
|
nil
|
|
217
217
|
end
|
|
218
218
|
|
|
@@ -222,8 +222,8 @@ module Cronwatch
|
|
|
222
222
|
statuses = Array(from_statuses).map(&:to_s)
|
|
223
223
|
return false if statuses.empty?
|
|
224
224
|
|
|
225
|
-
binds = [run.status.to_s, run.finished_at, run.duration_ms, run.error, run.output,
|
|
226
|
-
|
|
225
|
+
binds = [run.status.to_s, run.finished_at, run.duration_ms, text(run.error), text(run.output),
|
|
226
|
+
json_text(run.metrics || {}), run.id, *statuses]
|
|
227
227
|
changed = with_connection { |conn| conn.exec_update(update_run_if_sql(conn, statuses.length), NAME, binds) }
|
|
228
228
|
changed.to_i.positive?
|
|
229
229
|
end
|
|
@@ -253,7 +253,7 @@ module Cronwatch
|
|
|
253
253
|
end
|
|
254
254
|
|
|
255
255
|
def set_state(state)
|
|
256
|
-
write(:set_state, [state.job,
|
|
256
|
+
write(:set_state, [state.job, json_text(state.to_h)])
|
|
257
257
|
nil
|
|
258
258
|
end
|
|
259
259
|
|
|
@@ -262,10 +262,10 @@ module Cronwatch
|
|
|
262
262
|
# Returns whether it wrote. This is what keeps two processes sharing the
|
|
263
263
|
# database (Ruby or Node) from overwriting each other's updates.
|
|
264
264
|
def compare_and_set_state(state, expected_version)
|
|
265
|
-
|
|
265
|
+
state_text = json_text(state.to_h)
|
|
266
266
|
changed =
|
|
267
|
-
if expected_version.zero? then write(:cas_insert, [state.job,
|
|
268
|
-
else write(:cas_update, [
|
|
267
|
+
if expected_version.zero? then write(:cas_insert, [state.job, state_text])
|
|
268
|
+
else write(:cas_update, [state_text, state.job, expected_version])
|
|
269
269
|
end
|
|
270
270
|
changed.to_i.positive?
|
|
271
271
|
end
|
|
@@ -395,6 +395,19 @@ module Cronwatch
|
|
|
395
395
|
|
|
396
396
|
# SQLite hands back JSON as TEXT; Postgres drivers may hand back JSONB
|
|
397
397
|
# parsed or as text, and BIGINT as a string.
|
|
398
|
+
# Postgres refuses U+0000 in TEXT and JSONB, and a refused write loses
|
|
399
|
+
# the whole row, so every dialect writes text without it: a run's
|
|
400
|
+
# trigger, output, error and metric names, and every key and string of
|
|
401
|
+
# a definition and a state. Identifiers (a job's name, a run's id) are
|
|
402
|
+
# written as given; the client refuses one with a NUL before it gets here.
|
|
403
|
+
def text(value)
|
|
404
|
+
value.nil? ? nil : Output.strip_nul(value.to_s)
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
def json_text(value)
|
|
408
|
+
Output.strip_json_nul(JS.json(value))
|
|
409
|
+
end
|
|
410
|
+
|
|
398
411
|
def json(value)
|
|
399
412
|
js_numbers(value.is_a?(String) ? JS.parse(value) : value)
|
|
400
413
|
end
|
|
@@ -28,7 +28,7 @@ module Cronwatch
|
|
|
28
28
|
existing = @jobs[definition.name]
|
|
29
29
|
@jobs[definition.name] = StoredJob.new(
|
|
30
30
|
name: definition.name,
|
|
31
|
-
definition:
|
|
31
|
+
definition: kept(definition, JobDefinition),
|
|
32
32
|
created_at: existing ? existing.created_at : now,
|
|
33
33
|
updated_at: now,
|
|
34
34
|
)
|
|
@@ -62,7 +62,7 @@ module Cronwatch
|
|
|
62
62
|
sync do
|
|
63
63
|
raise "run #{run.id} already exists" if @runs.key?(run.id)
|
|
64
64
|
|
|
65
|
-
@runs[run.id] =
|
|
65
|
+
@runs[run.id] = kept_run(run)
|
|
66
66
|
@order[run.id] = (@seq += 1)
|
|
67
67
|
end
|
|
68
68
|
nil
|
|
@@ -125,7 +125,7 @@ module Cronwatch
|
|
|
125
125
|
end
|
|
126
126
|
|
|
127
127
|
def set_state(state)
|
|
128
|
-
sync { @states[state.job] =
|
|
128
|
+
sync { @states[state.job] = kept(state, JobState) }
|
|
129
129
|
nil
|
|
130
130
|
end
|
|
131
131
|
|
|
@@ -136,7 +136,7 @@ module Cronwatch
|
|
|
136
136
|
sync do
|
|
137
137
|
next false unless Evaluate.state_version(@states[state.job]) == expected_version
|
|
138
138
|
|
|
139
|
-
@states[state.job] =
|
|
139
|
+
@states[state.job] = kept(state, JobState)
|
|
140
140
|
true
|
|
141
141
|
end
|
|
142
142
|
end
|
|
@@ -161,7 +161,7 @@ module Cronwatch
|
|
|
161
161
|
|
|
162
162
|
# `existing` with the fields an update writes taken from `run`.
|
|
163
163
|
def finished_fields(existing, run)
|
|
164
|
-
copy =
|
|
164
|
+
copy = kept_run(run)
|
|
165
165
|
existing.dup.tap do |r|
|
|
166
166
|
r.status = copy.status
|
|
167
167
|
r.finished_at = copy.finished_at
|
|
@@ -181,6 +181,21 @@ module Cronwatch
|
|
|
181
181
|
def clone(value, type)
|
|
182
182
|
type.from_h(JS.parse(JS.json(value.to_h)))
|
|
183
183
|
end
|
|
184
|
+
|
|
185
|
+
# Text is held as the SQL stores write it, without U+0000, so every
|
|
186
|
+
# store reads back the same. Identifiers are held as given.
|
|
187
|
+
def kept(value, type)
|
|
188
|
+
type.from_h(JS.parse(Output.strip_json_nul(JS.json(value.to_h))))
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def kept_run(run)
|
|
192
|
+
clone(run, Run).tap do |copy|
|
|
193
|
+
copy.trigger = Output.strip_nul(copy.trigger.to_s)
|
|
194
|
+
copy.output = copy.output && Output.strip_nul(copy.output)
|
|
195
|
+
copy.error = copy.error && Output.strip_nul(copy.error)
|
|
196
|
+
copy.metrics = JS.parse(Output.strip_json_nul(JS.json(copy.metrics || {})))
|
|
197
|
+
end
|
|
198
|
+
end
|
|
184
199
|
end
|
|
185
200
|
end
|
|
186
201
|
end
|
data/lib/cronwatch/version.rb
CHANGED