cronwatch 0.8.0 → 0.10.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/README.md +3 -3
- data/lib/cronwatch/alerts/discord.rb +15 -2
- data/lib/cronwatch/client.rb +233 -94
- data/lib/cronwatch/environment.rb +26 -0
- data/lib/cronwatch/evaluate.rb +121 -3
- data/lib/cronwatch/flight.rb +4 -0
- data/lib/cronwatch/job.rb +5 -2
- data/lib/cronwatch/output.rb +35 -1
- data/lib/cronwatch/pg_cron.rb +63 -14
- data/lib/cronwatch/scheduler.rb +73 -10
- data/lib/cronwatch/stores/active_record.rb +33 -12
- data/lib/cronwatch/stores/memory.rb +20 -5
- data/lib/cronwatch/ticker.rb +12 -0
- data/lib/cronwatch/types.rb +60 -5
- data/lib/cronwatch/version.rb +1 -1
- data/lib/cronwatch/web/app.rb +6 -4
- data/lib/cronwatch/web/html.rb +3 -2
- data/lib/generators/cronwatch/install/install_generator.rb +51 -7
- metadata +1 -1
data/lib/cronwatch/evaluate.rb
CHANGED
|
@@ -35,6 +35,14 @@ module Cronwatch
|
|
|
35
35
|
[ms, MAX_DURATION_MS].min
|
|
36
36
|
end
|
|
37
37
|
|
|
38
|
+
# When a silence of `ms` from `now` ends: a whole millisecond, never past
|
|
39
|
+
# MAX_DURATION_MS (2**53 - 1), however long the silence asked for. Every
|
|
40
|
+
# port sharing the store reads it back unchanged, where a larger number
|
|
41
|
+
# could wrap to a time long past and send alerts during the silence.
|
|
42
|
+
def silence_end(now, ms)
|
|
43
|
+
[now + (ms >= MAX_DURATION_MS ? MAX_DURATION_MS : ms.floor), MAX_DURATION_MS].min
|
|
44
|
+
end
|
|
45
|
+
|
|
38
46
|
# The version a stored state counts as for compare_and_set_state: its
|
|
39
47
|
# `version` when that is a whole number from 0 to MAX_DURATION_MS
|
|
40
48
|
# (2**53 - 1), else 0, as when it is absent. The SQL stores read it the
|
|
@@ -48,28 +56,137 @@ module Cronwatch
|
|
|
48
56
|
version.to_i
|
|
49
57
|
end
|
|
50
58
|
|
|
59
|
+
# The failures in a row a stored state counts as: its `consecutive_failures`
|
|
60
|
+
# when that is a whole number, held at MAX_DURATION_MS (2**53 - 1), and 0
|
|
61
|
+
# when it is negative or not a whole number. A foreign row's count past
|
|
62
|
+
# 2**53 stays at the top, as the SDK holds it, and a 1.5, "3" or -1
|
|
63
|
+
# counts as none.
|
|
64
|
+
def failure_count(state)
|
|
65
|
+
count = state&.consecutive_failures
|
|
66
|
+
return 0 unless count.is_a?(Integer) || (count.is_a?(Float) && count.finite? && count == count.floor)
|
|
67
|
+
return 0 unless count.positive?
|
|
68
|
+
|
|
69
|
+
[count, MAX_DURATION_MS].min.to_i
|
|
70
|
+
end
|
|
71
|
+
|
|
51
72
|
def empty_state(job)
|
|
52
73
|
JobState.new(job: job, open: {}, consecutive_failures: 0, silenced_until: nil, last_alert_at: nil,
|
|
53
74
|
pending_recovery: [], undelivered: [])
|
|
54
75
|
end
|
|
55
76
|
|
|
56
|
-
# A stored state with every field present, or a fresh one. State written
|
|
77
|
+
# A stored state with every field present, or a fresh one. State written
|
|
78
|
+
# by an older version lacks the newer fields. `sending` is the exception:
|
|
79
|
+
# it is there only while it holds an alert (see hold_alerts).
|
|
57
80
|
def normalize_state(state, job)
|
|
58
81
|
return empty_state(job) if state.nil?
|
|
59
82
|
|
|
60
83
|
state = JobState.from_h(state)
|
|
84
|
+
sending = state.sending
|
|
61
85
|
JobState.new(
|
|
62
86
|
job: state.job.nil? ? job : state.job,
|
|
63
87
|
open: (state.open || {}).dup,
|
|
64
|
-
consecutive_failures: state
|
|
88
|
+
consecutive_failures: failure_count(state),
|
|
65
89
|
silenced_until: state.silenced_until,
|
|
66
90
|
last_alert_at: state.last_alert_at,
|
|
67
91
|
pending_recovery: (state.pending_recovery || []).dup,
|
|
68
92
|
undelivered: (state.undelivered || []).dup,
|
|
69
93
|
version: state.version,
|
|
94
|
+
sending: sending.is_a?(Array) && !sending.empty? ? sending.dup : nil,
|
|
70
95
|
)
|
|
71
96
|
end
|
|
72
97
|
|
|
98
|
+
# ---------------------------------------------------------------- delivery
|
|
99
|
+
|
|
100
|
+
# Alerts kept per job for retry, and per job being sent; past it the oldest go.
|
|
101
|
+
MAX_UNDELIVERED = 20
|
|
102
|
+
|
|
103
|
+
# How long an alert in `sending` is left to the process sending it.
|
|
104
|
+
# Longer than any send takes: at most three alerts go out together, each
|
|
105
|
+
# with 25 seconds of triage and 15 of channels.
|
|
106
|
+
SEND_LEASE_MS = 5 * 60_000
|
|
107
|
+
|
|
108
|
+
# A state and how many alerts the queue let go to make it.
|
|
109
|
+
Held = Struct.new(:state, :dropped, keyword_init: true) do
|
|
110
|
+
def to_h = { "state" => state.to_h, "dropped" => dropped }
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Identifies an alert across retries, and in `sending`. `at` is printed
|
|
114
|
+
# as JavaScript prints a number.
|
|
115
|
+
def alert_key(alert)
|
|
116
|
+
at = alert.at
|
|
117
|
+
"#{alert.type}|#{at.is_a?(Numeric) ? JS.number(at) : at}|#{alert.run&.id}"
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# `alerts` added to the undelivered queue: one with the same key as a
|
|
121
|
+
# queued alert replaces it where it stands, the rest go at the end, and
|
|
122
|
+
# only the newest MAX_UNDELIVERED stay. `dropped` counts those let go.
|
|
123
|
+
def queue_undelivered(state, alerts)
|
|
124
|
+
following = clone_state(state)
|
|
125
|
+
by_key = alerts.to_h { |alert| [alert_key(alert), alert] }
|
|
126
|
+
queue = following.undelivered.map { |alert| by_key.fetch(alert_key(alert), alert) }
|
|
127
|
+
known = queue.to_set { |alert| alert_key(alert) }
|
|
128
|
+
queue.concat(alerts.reject { |alert| known.include?(alert_key(alert)) })
|
|
129
|
+
following.undelivered = queue.last(MAX_UNDELIVERED)
|
|
130
|
+
Held.new(state: following, dropped: [0, queue.length - MAX_UNDELIVERED].max)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# The outbox. Alerts just composed are written with the state that opens
|
|
134
|
+
# their condition, before any is sent, so a process that stops part way
|
|
135
|
+
# does not lose them: into `sending`, each with its lease ending at
|
|
136
|
+
# `until_at`, when this process sends them, or (deliver: :check) straight
|
|
137
|
+
# into the undelivered queue for a check elsewhere. `dropped` counts
|
|
138
|
+
# alerts let go past MAX_UNDELIVERED.
|
|
139
|
+
def hold_alerts(state, alerts, until_at, deferred)
|
|
140
|
+
return Held.new(state: state, dropped: 0) if alerts.empty?
|
|
141
|
+
return queue_undelivered(state, alerts) if deferred
|
|
142
|
+
|
|
143
|
+
following = clone_state(state)
|
|
144
|
+
entries = (following.sending || []) + alerts.map { |alert| { "until" => until_at, "alert" => alert } }
|
|
145
|
+
following.sending = entries.last(MAX_UNDELIVERED)
|
|
146
|
+
Held.new(state: following, dropped: [0, entries.length - MAX_UNDELIVERED].max)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Alerts in `sending` whose lease ran out by `now`: the process sending
|
|
150
|
+
# them stopped before it recorded how the send went. They go to the
|
|
151
|
+
# undelivered queue, where the retry sends them (with triage, which is
|
|
152
|
+
# never stored with them here) or drops them as stale. An entry that is
|
|
153
|
+
# not a Hash with an alert is dropped; one without a numeric `until`
|
|
154
|
+
# counts as run out.
|
|
155
|
+
def release_sending(state, now)
|
|
156
|
+
sending = state.sending.is_a?(Array) ? state.sending : []
|
|
157
|
+
lapsed, held = sending.partition { |entry| !(entry.is_a?(Hash) && entry["until"].is_a?(Numeric) && entry["until"] > now) }
|
|
158
|
+
return Held.new(state: state, dropped: 0) if lapsed.empty?
|
|
159
|
+
|
|
160
|
+
kept = state.dup
|
|
161
|
+
kept.sending = held
|
|
162
|
+
queue_undelivered(kept, lapsed.filter_map { |entry| sending_alert(entry) })
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# How a send went. Delivered and stale alerts leave the queue; failed
|
|
166
|
+
# ones replace their queued copy, so a triage made on this attempt is
|
|
167
|
+
# kept, or join the queue. Every one of them leaves `sending`.
|
|
168
|
+
# last_alert_at moves only on a delivery. `dropped` counts alerts let go
|
|
169
|
+
# past MAX_UNDELIVERED.
|
|
170
|
+
def record_sent(state, delivered, failed, stale, now)
|
|
171
|
+
following = clone_state(state)
|
|
172
|
+
done = (delivered + stale).to_set { |alert| alert_key(alert) }
|
|
173
|
+
following.undelivered = following.undelivered.reject { |alert| done.include?(alert_key(alert)) }
|
|
174
|
+
sent = (delivered + failed + stale).to_set { |alert| alert_key(alert) }
|
|
175
|
+
held = (following.sending || []).reject do |entry|
|
|
176
|
+
alert = sending_alert(entry)
|
|
177
|
+
alert && sent.include?(alert_key(alert))
|
|
178
|
+
end
|
|
179
|
+
following.sending = held.empty? ? nil : held
|
|
180
|
+
following.last_alert_at = now if delivered.any?
|
|
181
|
+
queue_undelivered(following, failed)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# The alert of an entry of `sending`, or nil when it has none, or one
|
|
185
|
+
# that did not parse as an alert (which could not be sent either).
|
|
186
|
+
def sending_alert(entry)
|
|
187
|
+
entry.is_a?(Hash) && entry["alert"].is_a?(Alert) ? entry["alert"] : nil
|
|
188
|
+
end
|
|
189
|
+
|
|
73
190
|
def clone_state(state)
|
|
74
191
|
normalize_state(state, state.job)
|
|
75
192
|
end
|
|
@@ -233,7 +350,8 @@ module Cronwatch
|
|
|
233
350
|
end
|
|
234
351
|
|
|
235
352
|
# failed or timeout
|
|
236
|
-
|
|
353
|
+
# Held at the top, as the SDK holds it: a count at the limit stays there.
|
|
354
|
+
next_state.consecutive_failures = [next_state.consecutive_failures + 1, MAX_DURATION_MS].min
|
|
237
355
|
close_condition(next_state, :missed)
|
|
238
356
|
threshold = [1, definition.failures_before_alert || 1].max
|
|
239
357
|
condition = run.status == :timeout ? :stuck : :failed
|
data/lib/cronwatch/flight.rb
CHANGED
|
@@ -4,7 +4,11 @@ module Cronwatch
|
|
|
4
4
|
class Client
|
|
5
5
|
# One shared check: the first caller runs it, the others wait for its result.
|
|
6
6
|
class Flight
|
|
7
|
+
# The thread running the check.
|
|
8
|
+
attr_reader :owner
|
|
9
|
+
|
|
7
10
|
def initialize
|
|
11
|
+
@owner = Thread.current
|
|
8
12
|
@lock = Mutex.new
|
|
9
13
|
@done = ConditionVariable.new
|
|
10
14
|
@finished = false
|
data/lib/cronwatch/job.rb
CHANGED
|
@@ -97,7 +97,7 @@ module Cronwatch
|
|
|
97
97
|
# Collects a run's output and metrics while its block runs.
|
|
98
98
|
class RunRecorder
|
|
99
99
|
# Lines are dropped from the front once the output is well past the cap;
|
|
100
|
-
# Output.
|
|
100
|
+
# Output.redact_and_cap trims it exactly at the end.
|
|
101
101
|
KEEP = 64 * 1024
|
|
102
102
|
|
|
103
103
|
attr_reader :context, :signal
|
|
@@ -136,8 +136,11 @@ module Cronwatch
|
|
|
136
136
|
@lock.synchronize { @metrics[name] = value }
|
|
137
137
|
end
|
|
138
138
|
|
|
139
|
+
# The lines still held (past 64 KB the oldest are let go), joined and not
|
|
140
|
+
# yet capped: the client redacts them first, then caps them
|
|
141
|
+
# (Output.redact_and_cap).
|
|
139
142
|
def output
|
|
140
|
-
@lock.synchronize { @lines.empty? ? nil :
|
|
143
|
+
@lock.synchronize { @lines.empty? ? nil : @lines.join("\n") }
|
|
141
144
|
end
|
|
142
145
|
|
|
143
146
|
# What an expect rule is checked against: everything logged, or when that
|
data/lib/cronwatch/output.rb
CHANGED
|
@@ -129,7 +129,30 @@ module Cronwatch
|
|
|
129
129
|
text = strip_nul(utf8(text))
|
|
130
130
|
return text if JS.length16(text) <= CAP
|
|
131
131
|
|
|
132
|
-
"
|
|
132
|
+
"#{TRIMMED}#{JS.tail16(text, CAP)}"
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
TRIMMED = "[earlier output trimmed]\n"
|
|
136
|
+
|
|
137
|
+
# How much text before the kept tail redaction reads, and never keeps:
|
|
138
|
+
# three times the longest secret a default pattern can match (a PEM
|
|
139
|
+
# key's 16 KB body with its header and footer, under CAP + 1024), since a
|
|
140
|
+
# replacement grows what it replaces at most threefold.
|
|
141
|
+
REDACT_EDGE = 3 * (CAP + 1024)
|
|
142
|
+
|
|
143
|
+
# Output or an error as it is stored: redacted, then capped like cap, so
|
|
144
|
+
# the cut cannot fall inside a secret and keep what follows its label.
|
|
145
|
+
# Text of at most CAP + REDACT_EDGE units is redacted whole. Longer text
|
|
146
|
+
# is cut to that many units from its end first, and after redacting, the
|
|
147
|
+
# first REDACT_EDGE units are never kept: a secret whose label fell before
|
|
148
|
+
# that cut is left out with them. NULs go before and after `redact`.
|
|
149
|
+
def redact_and_cap(text, redact)
|
|
150
|
+
clean = strip_nul(utf8(text))
|
|
151
|
+
return cap(redact.call(clean)) if JS.length16(clean) <= CAP + REDACT_EDGE
|
|
152
|
+
|
|
153
|
+
redacted = strip_nul(utf8(redact.call(JS.tail16(clean, CAP + REDACT_EDGE))))
|
|
154
|
+
length = JS.length16(redacted)
|
|
155
|
+
"#{TRIMMED}#{JS.tail16(redacted, [[CAP, length - REDACT_EDGE].min, 0].max)}"
|
|
133
156
|
end
|
|
134
157
|
|
|
135
158
|
# Removes every U+0000.
|
|
@@ -137,8 +160,19 @@ module Cronwatch
|
|
|
137
160
|
text.include?("\0") ? text.delete("\0") : text
|
|
138
161
|
end
|
|
139
162
|
|
|
163
|
+
# Removes every U+0000 from JSON text, keys and strings alike, by dropping
|
|
164
|
+
# each \u0000 escape (a NUL can appear in JSON no other way). Escapes are
|
|
165
|
+
# read left to right in pairs, so an escaped backslash followed by "u0000"
|
|
166
|
+
# is left as it is.
|
|
167
|
+
def strip_json_nul(json)
|
|
168
|
+
return json unless json.include?("\\u0000")
|
|
169
|
+
|
|
170
|
+
json.gsub(/\\(u0000|.)/m) { Regexp.last_match(1) == "u0000" ? "" : Regexp.last_match(0) }
|
|
171
|
+
end
|
|
172
|
+
|
|
140
173
|
# "Name: message" and the first five backtrace lines, each written as
|
|
141
174
|
# " at <line>" like the frames of a JavaScript stack, capped like output.
|
|
175
|
+
# The client stores describe_error through redact_and_cap instead.
|
|
142
176
|
# An exception that stops the thread rather than reporting a problem
|
|
143
177
|
# (Interrupt, SystemExit, Sidekiq::Shutdown, a Timeout) is written
|
|
144
178
|
# "Interrupted: <class>", with its message when it says more.
|
data/lib/cronwatch/pg_cron.rb
CHANGED
|
@@ -13,8 +13,9 @@ module Cronwatch
|
|
|
13
13
|
# with `name` and `sync(host)`: on every check the client calls sync with
|
|
14
14
|
# itself as the host, and the source declares jobs (`host.job`), reads the
|
|
15
15
|
# store (`host.store`), records the runs it found (`host.record_run`) and
|
|
16
|
-
# reports problems (`host.on_error(error, where)`).
|
|
17
|
-
#
|
|
16
|
+
# reports problems (`host.on_error(error, where)`). A host with
|
|
17
|
+
# `defined_jobs` lets a source declare again a job forgotten since. sync
|
|
18
|
+
# returns the alerts recording sent, or anything else for none.
|
|
18
19
|
module Sources
|
|
19
20
|
# Watches pg_cron jobs, which run inside Postgres where nothing can wrap
|
|
20
21
|
# them (the SDK's sources/pgcron.ts). As a source, on every check it
|
|
@@ -41,8 +42,10 @@ module Cronwatch
|
|
|
41
42
|
# prefix: put before every job name, to keep them apart from your own ("db:"). Also keeps run ids apart.
|
|
42
43
|
# job_name: a callable giving the CronWatch name for a Job. Default its jobname with anything other than
|
|
43
44
|
# letters, digits, ".", "_", ":" and "-" turned into "-", or "pg_cron:<jobid>" when it has none.
|
|
44
|
-
# The prefix goes in front either way.
|
|
45
|
-
#
|
|
45
|
+
# The prefix goes in front either way. One that raises or returns no name, like a `jobs` or
|
|
46
|
+
# `options` callable that raises, is reported once and fails only that job, which keeps its last
|
|
47
|
+
# declaration until the callable works again.
|
|
48
|
+
# options: grace, timeout, max_duration, expect and the rest, for every job (a hash) or per job (a callable
|
|
46
49
|
# given a Job). The schedule and timezone always come from pg_cron.
|
|
47
50
|
# timezone: the timezone pg_cron reads its cron expressions in. Default the server's cron.timezone, read from
|
|
48
51
|
# pg_settings, which shows it only to roles with pg_read_all_settings; UTC (pg_cron's default) is
|
|
@@ -110,6 +113,8 @@ module Cronwatch
|
|
|
110
113
|
@retired = Set.new
|
|
111
114
|
@scanned = false
|
|
112
115
|
@warned = Set.new
|
|
116
|
+
# Jobids whose callback failed, reported once until it works again.
|
|
117
|
+
@failing = Set.new
|
|
113
118
|
@name = "pg_cron"
|
|
114
119
|
end
|
|
115
120
|
|
|
@@ -269,8 +274,7 @@ module Cronwatch
|
|
|
269
274
|
"scheduled: connect as that role, or give this one BYPASSRLS.")
|
|
270
275
|
end
|
|
271
276
|
all = rows.map { |r| job_from(r) }
|
|
272
|
-
|
|
273
|
-
names, definitions = declare(host, jobs, timezone, recording)
|
|
277
|
+
names, definitions = declare(host, all, timezone, recording)
|
|
274
278
|
retire_unused(host, names, definitions, all)
|
|
275
279
|
return [] if !recording || names.empty?
|
|
276
280
|
|
|
@@ -316,18 +320,63 @@ module Cronwatch
|
|
|
316
320
|
/\A\d{1,15}\z/.match?(rest) ? rest.to_i : nil
|
|
317
321
|
end
|
|
318
322
|
|
|
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,
|
|
323
|
+
# Declares each job picked. A paused one (active = false) keeps its failures but loses its schedule, so it
|
|
324
|
+
# is not missed. Returns [{ jobid => name }, { jobid => definition as declared }].
|
|
325
|
+
def declare(host, all, timezone, recording)
|
|
326
|
+
# One forgotten since it was declared (the dashboard's forget) is declared again, though unchanged:
|
|
327
|
+
# record_run takes runs only of a declared job.
|
|
328
|
+
live = host.respond_to?(:defined_jobs) ? host.defined_jobs.to_set(&:name) : nil
|
|
322
329
|
names = {}
|
|
323
330
|
definitions = {}
|
|
324
331
|
used = Set.new
|
|
325
|
-
jobs
|
|
326
|
-
|
|
332
|
+
# A callback of the app's (jobs, job_name, options) that raised, or a job_name that gave no name, fails
|
|
333
|
+
# only its job, as a bad row does: reported once until it works again, and the job carries on as last
|
|
334
|
+
# declared (skipped when it never was), so its runs are still copied.
|
|
335
|
+
trouble = lambda do |job, what|
|
|
336
|
+
unless @failing.include?(job.jobid)
|
|
337
|
+
@failing << job.jobid
|
|
338
|
+
host.on_error(RuntimeError.new("pg_cron job #{job.jobid}: #{what}; it keeps its last declaration until that works"),
|
|
339
|
+
"source pg_cron")
|
|
340
|
+
end
|
|
341
|
+
last = @known[job.jobid]
|
|
342
|
+
next if last.nil? || used.include?(last[:name])
|
|
343
|
+
|
|
344
|
+
names[job.jobid] = last[:name]
|
|
345
|
+
definitions[job.jobid] = last[:definition]
|
|
346
|
+
used << last[:name]
|
|
347
|
+
end
|
|
348
|
+
all.each do |job|
|
|
349
|
+
begin
|
|
350
|
+
picked = picks?(job)
|
|
351
|
+
rescue StandardError => e
|
|
352
|
+
trouble.call(job, "the jobs callback raised #{e.class}: #{e.message}")
|
|
353
|
+
next
|
|
354
|
+
end
|
|
355
|
+
unless picked
|
|
356
|
+
@failing.delete(job.jobid)
|
|
357
|
+
next
|
|
358
|
+
end
|
|
359
|
+
begin
|
|
360
|
+
base = @job_name ? @job_name.call(job) : PgCron.job_name(job)
|
|
361
|
+
rescue StandardError => e
|
|
362
|
+
trouble.call(job, "job_name raised #{e.class}: #{e.message}")
|
|
363
|
+
next
|
|
364
|
+
end
|
|
365
|
+
unless base.is_a?(String) || base.is_a?(Symbol)
|
|
366
|
+
trouble.call(job, "job_name returned #{base.nil? ? "nil" : base.class}, not a name")
|
|
367
|
+
next
|
|
368
|
+
end
|
|
369
|
+
begin
|
|
370
|
+
extra = (@options.respond_to?(:call) ? @options.call(job) : @options) || {}
|
|
371
|
+
extra = extra.to_h.transform_keys(&:to_sym).except(*SCHEDULE_ONLY)
|
|
372
|
+
rescue StandardError => e
|
|
373
|
+
trouble.call(job, "the options callback raised #{e.class}: #{e.message}")
|
|
374
|
+
next
|
|
375
|
+
end
|
|
376
|
+
@failing.delete(job.jobid)
|
|
377
|
+
name = @prefix + base.to_s
|
|
327
378
|
name = "#{name}:#{job.jobid}" if used.include?(name)
|
|
328
379
|
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
380
|
schedule = job.active && recording ? PgCron.schedule(job.schedule) : nil
|
|
332
381
|
definition = {
|
|
333
382
|
description: "pg_cron job #{job.jobid} in #{job.database} as #{job.username}#{job.active ? "" : " (paused)"}",
|
|
@@ -336,7 +385,7 @@ module Cronwatch
|
|
|
336
385
|
definition.merge!(schedule: schedule, timezone: timezone) if schedule
|
|
337
386
|
begin
|
|
338
387
|
key = definition_key(definition)
|
|
339
|
-
if @declared[name] != key
|
|
388
|
+
if @declared[name] != key || (live && !live.include?(name))
|
|
340
389
|
begin
|
|
341
390
|
host.job(name, **definition)
|
|
342
391
|
rescue StandardError => e
|
data/lib/cronwatch/scheduler.rb
CHANGED
|
@@ -76,6 +76,18 @@ module Cronwatch
|
|
|
76
76
|
raise NotImplementedError
|
|
77
77
|
end
|
|
78
78
|
|
|
79
|
+
# The entries a class's own schedule (schedule: :from_scheduler) is
|
|
80
|
+
# looked up in: #entries, unless a source says otherwise.
|
|
81
|
+
def schedule_entries
|
|
82
|
+
entries
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Entries for other environments than the one read, which say a class
|
|
86
|
+
# is scheduled, only not here. None, unless a source says otherwise.
|
|
87
|
+
def elsewhere_entries
|
|
88
|
+
[]
|
|
89
|
+
end
|
|
90
|
+
|
|
79
91
|
private
|
|
80
92
|
|
|
81
93
|
# The config: the Hash or Array given, or the file after ERB, as both
|
|
@@ -146,19 +158,32 @@ module Cronwatch
|
|
|
146
158
|
def entries
|
|
147
159
|
return [] if skip_recurring?
|
|
148
160
|
|
|
149
|
-
|
|
161
|
+
schedule_entries
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# The tasks, read even where SOLID_QUEUE_SKIP_RECURRING is set: the
|
|
165
|
+
# skip means only that this process enqueues none, as when one of
|
|
166
|
+
# several bin/jobs schedules and the others still perform what it
|
|
167
|
+
# enqueues, so a class's own schedule is still the file's.
|
|
168
|
+
def schedule_entries
|
|
169
|
+
config = parsed
|
|
150
170
|
return [] if config.nil?
|
|
151
|
-
raise Error, "cronwatch: #{label} is not a map of recurring tasks" unless config.is_a?(Hash)
|
|
152
171
|
|
|
153
|
-
config = symbolize(config)
|
|
154
172
|
config = config[env.to_sym] if config[env.to_sym]
|
|
155
|
-
|
|
173
|
+
tasks(config)
|
|
174
|
+
end
|
|
156
175
|
|
|
157
|
-
|
|
158
|
-
|
|
176
|
+
# The tasks of the file's other environments' sections (Rails 8's
|
|
177
|
+
# recurring.yml has only production:, so development and test read
|
|
178
|
+
# none), for a class scheduled there and not here.
|
|
179
|
+
def elsewhere_entries
|
|
180
|
+
config = parsed
|
|
181
|
+
return [] if config.nil?
|
|
159
182
|
|
|
160
|
-
|
|
161
|
-
|
|
183
|
+
config.flat_map do |key, section|
|
|
184
|
+
next [] if key == env.to_sym || !section.is_a?(Hash) || section.key?(:schedule)
|
|
185
|
+
|
|
186
|
+
tasks(section)
|
|
162
187
|
end
|
|
163
188
|
end
|
|
164
189
|
|
|
@@ -241,6 +266,27 @@ module Cronwatch
|
|
|
241
266
|
def present(value)
|
|
242
267
|
value.nil? || value.to_s.strip.empty? ? nil : value.to_s
|
|
243
268
|
end
|
|
269
|
+
|
|
270
|
+
# The file's map, keys as symbols, or nil when there is no file.
|
|
271
|
+
def parsed
|
|
272
|
+
config = read
|
|
273
|
+
return nil if config.nil?
|
|
274
|
+
raise Error, "cronwatch: #{label} is not a map of recurring tasks" unless config.is_a?(Hash)
|
|
275
|
+
|
|
276
|
+
symbolize(config)
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
# The tasks of one map of them: each with a schedule is an entry.
|
|
280
|
+
def tasks(config)
|
|
281
|
+
return [] unless config.is_a?(Hash)
|
|
282
|
+
|
|
283
|
+
config.filter_map do |key, options|
|
|
284
|
+
next unless options.is_a?(Hash) && options.key?(:schedule)
|
|
285
|
+
|
|
286
|
+
Entry.new(source: self, key: key.to_s, class_name: present(options[:class]), command: present(options[:command]),
|
|
287
|
+
schedule: options[:schedule], description: present(options[:description]), disabled: false)
|
|
288
|
+
end
|
|
289
|
+
end
|
|
244
290
|
end
|
|
245
291
|
|
|
246
292
|
# sidekiq-cron's schedule: config/schedule.yml (or the file its
|
|
@@ -370,7 +416,13 @@ module Cronwatch
|
|
|
370
416
|
end
|
|
371
417
|
|
|
372
418
|
# { schedule:, timezone: } for the class's one entry in the scheduler's
|
|
373
|
-
# config
|
|
419
|
+
# config, read even in a process told to schedule nothing
|
|
420
|
+
# (SOLID_QUEUE_SKIP_RECURRING), since another process schedules it.
|
|
421
|
+
# { schedule: nil } for a class scheduled only in another environment's
|
|
422
|
+
# section of the file (Rails 8's recurring.yml has only production:), so
|
|
423
|
+
# it loads in development and test, declared without a schedule, as
|
|
424
|
+
# nothing schedules it there. Raises Error when no environment has it,
|
|
425
|
+
# or this one has it more than once.
|
|
374
426
|
def schedule_for(klass)
|
|
375
427
|
name = klass.is_a?(Module) ? klass.name : klass.to_s
|
|
376
428
|
found = sources
|
|
@@ -379,8 +431,11 @@ module Cronwatch
|
|
|
379
431
|
"set Cronwatch::Scheduler.sources to say where the schedule is"
|
|
380
432
|
end
|
|
381
433
|
|
|
382
|
-
|
|
434
|
+
of_class = ->(entry) { !entry.disabled && same_class?(entry.class_name, name) }
|
|
435
|
+
matches = found.flat_map { |source| read_entries(source, :schedule_entries) }.select(&of_class)
|
|
383
436
|
if matches.empty?
|
|
437
|
+
return { schedule: nil } if found.flat_map { |source| read_entries(source, :elsewhere_entries) }.any?(&of_class)
|
|
438
|
+
|
|
384
439
|
raise Error, "cronwatch: #{name} uses schedule: :from_scheduler, but no enabled entry in " \
|
|
385
440
|
"#{found.map(&:label).join(" or ")} has class #{name}"
|
|
386
441
|
end
|
|
@@ -392,6 +447,14 @@ module Cronwatch
|
|
|
392
447
|
matches.first.convert
|
|
393
448
|
end
|
|
394
449
|
|
|
450
|
+
# A source's entries of that kind, for a source of the app's own that
|
|
451
|
+
# has only #entries.
|
|
452
|
+
def read_entries(source, kind)
|
|
453
|
+
return source.public_send(kind) if source.respond_to?(kind)
|
|
454
|
+
|
|
455
|
+
kind == :schedule_entries ? source.entries : []
|
|
456
|
+
end
|
|
457
|
+
|
|
395
458
|
# Declares a job for every enabled entry once the app has booted, so a
|
|
396
459
|
# check reports one that never runs: a class that calls `cronwatch`
|
|
397
460
|
# declares itself, any other class is named as `cronwatch` would name
|
|
@@ -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
|
|
@@ -417,12 +430,20 @@ module Cronwatch
|
|
|
417
430
|
end
|
|
418
431
|
|
|
419
432
|
def row_to_job(row)
|
|
420
|
-
StoredJob.new(name: row["name"], definition: JobDefinition.from_h(
|
|
433
|
+
StoredJob.new(name: row["name"], definition: JobDefinition.from_h(definition_json(row["definition"])),
|
|
421
434
|
created_at: int(row["created_at"]), updated_at: int(row["updated_at"]))
|
|
422
435
|
end
|
|
423
436
|
|
|
437
|
+
# A definition that does not parse (nested past JSON.parse's limit, say)
|
|
438
|
+
# reads as nil, which JobDefinition.from_h marks unreadable.
|
|
439
|
+
def definition_json(value)
|
|
440
|
+
json(value)
|
|
441
|
+
rescue ::JSON::ParserError
|
|
442
|
+
nil
|
|
443
|
+
end
|
|
444
|
+
|
|
424
445
|
def row_to_run(row)
|
|
425
|
-
metrics = row["metrics"].nil? ? {} : json(row["metrics"])
|
|
446
|
+
metrics = row["metrics"].nil? ? {} : Run.metrics_from(json(row["metrics"]))
|
|
426
447
|
Run.new(id: row["id"], job: row["job"], status: row["status"].to_sym, started_at: int(row["started_at"]),
|
|
427
448
|
finished_at: int(row["finished_at"]), duration_ms: int(row["duration_ms"]), error: row["error"],
|
|
428
449
|
output: row["output"], metrics: metrics, trigger: row["trigger"])
|
|
@@ -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
|