cronwatch 0.9.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 +209 -93
- data/lib/cronwatch/environment.rb +26 -0
- data/lib/cronwatch/evaluate.rb +105 -1
- data/lib/cronwatch/flight.rb +4 -0
- data/lib/cronwatch/job.rb +5 -2
- data/lib/cronwatch/output.rb +25 -1
- data/lib/cronwatch/pg_cron.rb +7 -3
- data/lib/cronwatch/scheduler.rb +73 -10
- data/lib/cronwatch/stores/active_record.rb +10 -2
- 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
|
@@ -25,5 +25,31 @@ module Cronwatch
|
|
|
25
25
|
def production?
|
|
26
26
|
name == "production"
|
|
27
27
|
end
|
|
28
|
+
|
|
29
|
+
# Whether development or test was named by the app rather than by its
|
|
30
|
+
# server, for the dashboard's own token (Cronwatch::Web). Puma, Unicorn,
|
|
31
|
+
# Thin and rackup set RACK_ENV to "development" when nothing names an
|
|
32
|
+
# environment, so under one of them, in production too, RACK_ENV alone
|
|
33
|
+
# reads "development". There it takes Rails.env, RAILS_ENV or APP_ENV
|
|
34
|
+
# (Sinatra's) to say development; RACK_ENV "test" still counts, as no
|
|
35
|
+
# server sets it.
|
|
36
|
+
def stated_development?
|
|
37
|
+
return false unless development?
|
|
38
|
+
return true if defined?(::Rails) && ::Rails.respond_to?(:env) && !::Rails.env.to_s.empty?
|
|
39
|
+
|
|
40
|
+
stated = [ENV.fetch("RAILS_ENV", nil), ENV.fetch("APP_ENV", nil)].find { |value| value && !value.empty? }
|
|
41
|
+
return DEVELOPMENT.include?(stated) unless stated.nil?
|
|
42
|
+
|
|
43
|
+
name == "test" || !defaulting_server?
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# A server that sets RACK_ENV when it is unset is running this process.
|
|
47
|
+
def defaulting_server?
|
|
48
|
+
return true if defined?(::Puma::Server) || defined?(::Puma::Launcher) || defined?(::Unicorn::HttpServer) ||
|
|
49
|
+
defined?(::Thin::Server) || defined?(::Rackup::Server)
|
|
50
|
+
|
|
51
|
+
# Rack 2 autoloads its server: counted only once it has loaded.
|
|
52
|
+
defined?(::Rack) && ::Rack.const_defined?(:Server, false) && ::Rack.autoload?(:Server).nil?
|
|
53
|
+
end
|
|
28
54
|
end
|
|
29
55
|
end
|
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
|
|
@@ -66,11 +74,14 @@ module Cronwatch
|
|
|
66
74
|
pending_recovery: [], undelivered: [])
|
|
67
75
|
end
|
|
68
76
|
|
|
69
|
-
# 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).
|
|
70
80
|
def normalize_state(state, job)
|
|
71
81
|
return empty_state(job) if state.nil?
|
|
72
82
|
|
|
73
83
|
state = JobState.from_h(state)
|
|
84
|
+
sending = state.sending
|
|
74
85
|
JobState.new(
|
|
75
86
|
job: state.job.nil? ? job : state.job,
|
|
76
87
|
open: (state.open || {}).dup,
|
|
@@ -80,9 +91,102 @@ module Cronwatch
|
|
|
80
91
|
pending_recovery: (state.pending_recovery || []).dup,
|
|
81
92
|
undelivered: (state.undelivered || []).dup,
|
|
82
93
|
version: state.version,
|
|
94
|
+
sending: sending.is_a?(Array) && !sending.empty? ? sending.dup : nil,
|
|
83
95
|
)
|
|
84
96
|
end
|
|
85
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
|
+
|
|
86
190
|
def clone_state(state)
|
|
87
191
|
normalize_state(state, state.job)
|
|
88
192
|
end
|
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.
|
|
@@ -149,6 +172,7 @@ module Cronwatch
|
|
|
149
172
|
|
|
150
173
|
# "Name: message" and the first five backtrace lines, each written as
|
|
151
174
|
# " at <line>" like the frames of a JavaScript stack, capped like output.
|
|
175
|
+
# The client stores describe_error through redact_and_cap instead.
|
|
152
176
|
# An exception that stops the thread rather than reporting a problem
|
|
153
177
|
# (Interrupt, SystemExit, Sidekiq::Shutdown, a Timeout) is written
|
|
154
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
|
|
@@ -322,6 +323,9 @@ module Cronwatch
|
|
|
322
323
|
# Declares each job picked. A paused one (active = false) keeps its failures but loses its schedule, so it
|
|
323
324
|
# is not missed. Returns [{ jobid => name }, { jobid => definition as declared }].
|
|
324
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
|
|
325
329
|
names = {}
|
|
326
330
|
definitions = {}
|
|
327
331
|
used = Set.new
|
|
@@ -381,7 +385,7 @@ module Cronwatch
|
|
|
381
385
|
definition.merge!(schedule: schedule, timezone: timezone) if schedule
|
|
382
386
|
begin
|
|
383
387
|
key = definition_key(definition)
|
|
384
|
-
if @declared[name] != key
|
|
388
|
+
if @declared[name] != key || (live && !live.include?(name))
|
|
385
389
|
begin
|
|
386
390
|
host.job(name, **definition)
|
|
387
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
|
|
@@ -430,12 +430,20 @@ module Cronwatch
|
|
|
430
430
|
end
|
|
431
431
|
|
|
432
432
|
def row_to_job(row)
|
|
433
|
-
StoredJob.new(name: row["name"], definition: JobDefinition.from_h(
|
|
433
|
+
StoredJob.new(name: row["name"], definition: JobDefinition.from_h(definition_json(row["definition"])),
|
|
434
434
|
created_at: int(row["created_at"]), updated_at: int(row["updated_at"]))
|
|
435
435
|
end
|
|
436
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
|
+
|
|
437
445
|
def row_to_run(row)
|
|
438
|
-
metrics = row["metrics"].nil? ? {} : json(row["metrics"])
|
|
446
|
+
metrics = row["metrics"].nil? ? {} : Run.metrics_from(json(row["metrics"]))
|
|
439
447
|
Run.new(id: row["id"], job: row["job"], status: row["status"].to_sym, started_at: int(row["started_at"]),
|
|
440
448
|
finished_at: int(row["finished_at"]), duration_ms: int(row["duration_ms"]), error: row["error"],
|
|
441
449
|
output: row["output"], metrics: metrics, trigger: row["trigger"])
|
data/lib/cronwatch/ticker.rb
CHANGED
|
@@ -39,6 +39,18 @@ module Cronwatch
|
|
|
39
39
|
end
|
|
40
40
|
end
|
|
41
41
|
|
|
42
|
+
# False once the thread has ended: stopped, or killed from outside.
|
|
43
|
+
def alive?
|
|
44
|
+
@thread.alive?
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Waits for the thread to end: once stopped, after the tick under way.
|
|
48
|
+
# Returns at once when called from the thread itself.
|
|
49
|
+
def join
|
|
50
|
+
@thread.join unless Thread.current.equal?(@thread)
|
|
51
|
+
nil
|
|
52
|
+
end
|
|
53
|
+
|
|
42
54
|
private
|
|
43
55
|
|
|
44
56
|
# False once stopped.
|
data/lib/cronwatch/types.rb
CHANGED
|
@@ -78,15 +78,25 @@ module Cronwatch
|
|
|
78
78
|
|
|
79
79
|
FIELDS.each_key { |field| define_method(field) { @fields[field] } }
|
|
80
80
|
|
|
81
|
+
# What from_h makes a stored definition that is not a JSON object from.
|
|
82
|
+
UNREADABLE = {}.freeze
|
|
83
|
+
|
|
81
84
|
def initialize(fields = {})
|
|
82
85
|
@fields = {}
|
|
83
86
|
fields.each { |k, v| @fields[k.is_a?(Symbol) ? k : k.to_s] = v }
|
|
84
87
|
@fields.freeze
|
|
88
|
+
@unreadable = fields.equal?(UNREADABLE)
|
|
85
89
|
freeze
|
|
86
90
|
end
|
|
87
91
|
|
|
92
|
+
# A stored definition that is not a JSON object (null, a string, a
|
|
93
|
+
# number, written by hand or by something else) reads as an empty one
|
|
94
|
+
# marked unreadable, so the one row does not stop every other job being
|
|
95
|
+
# listed; a check or a dashboard read reports that job and shows it as
|
|
96
|
+
# failing (see Client#evaluable).
|
|
88
97
|
def self.from_h(hash)
|
|
89
98
|
return hash if hash.is_a?(JobDefinition)
|
|
99
|
+
return new(UNREADABLE) unless hash.is_a?(Hash)
|
|
90
100
|
|
|
91
101
|
new(hash.each_with_object({}) { |(k, v), out| out[BY_JSON[k.to_s] || k.to_s] = v })
|
|
92
102
|
end
|
|
@@ -95,6 +105,11 @@ module Cronwatch
|
|
|
95
105
|
@fields[field]
|
|
96
106
|
end
|
|
97
107
|
|
|
108
|
+
# True for a stored definition that was not a JSON object. See from_h.
|
|
109
|
+
def unreadable?
|
|
110
|
+
@unreadable
|
|
111
|
+
end
|
|
112
|
+
|
|
98
113
|
def key?(field)
|
|
99
114
|
@fields.key?(field)
|
|
100
115
|
end
|
|
@@ -152,7 +167,7 @@ module Cronwatch
|
|
|
152
167
|
duration_ms: Naming.fetch(hash, "durationMs"),
|
|
153
168
|
error: Naming.fetch(hash, "error"),
|
|
154
169
|
output: Naming.fetch(hash, "output"),
|
|
155
|
-
metrics: (Naming.fetch(hash, "metrics")
|
|
170
|
+
metrics: Run.metrics_from(Naming.fetch(hash, "metrics")),
|
|
156
171
|
trigger: Naming.fetch(hash, "trigger", "run"),
|
|
157
172
|
)
|
|
158
173
|
end
|
|
@@ -161,10 +176,16 @@ module Cronwatch
|
|
|
161
176
|
{
|
|
162
177
|
"id" => id, "job" => job, "status" => status.to_s, "startedAt" => started_at, "finishedAt" => finished_at,
|
|
163
178
|
"durationMs" => duration_ms, "error" => error, "output" => output,
|
|
164
|
-
"metrics" => (metrics
|
|
179
|
+
"metrics" => Run.metrics_from(metrics), "trigger" => trigger,
|
|
165
180
|
}
|
|
166
181
|
end
|
|
167
182
|
|
|
183
|
+
# Stored metrics as a Hash with string keys. Anything else (a string or
|
|
184
|
+
# an array a foreign or corrupted row holds) reads as none.
|
|
185
|
+
def self.metrics_from(value)
|
|
186
|
+
value.is_a?(Hash) ? value.transform_keys(&:to_s) : {}
|
|
187
|
+
end
|
|
188
|
+
|
|
168
189
|
def running? = status == :running
|
|
169
190
|
def ok? = status == :ok
|
|
170
191
|
end
|
|
@@ -255,8 +276,14 @@ module Cronwatch
|
|
|
255
276
|
# `version` goes up by one on every write, so a store can refuse a write
|
|
256
277
|
# made from a stale read (see Stores::Memory#compare_and_set_state). Absent
|
|
257
278
|
# (nil) counts as 0.
|
|
279
|
+
#
|
|
280
|
+
# `sending` is the outbox (see Evaluate.hold_alerts): entries of
|
|
281
|
+
# { "until" => epoch ms, "alert" => Alert }, read leniently, since an entry
|
|
282
|
+
# a foreign writer left malformed must not make the whole state unreadable:
|
|
283
|
+
# an entry that is not a Hash, or whose alert is not one, is kept as it
|
|
284
|
+
# came. nil when there is none; it is never written empty.
|
|
258
285
|
JobState = Struct.new(:job, :open, :consecutive_failures, :silenced_until, :last_alert_at, :pending_recovery,
|
|
259
|
-
:undelivered, :version, keyword_init: true) do
|
|
286
|
+
:undelivered, :version, :sending, keyword_init: true) do
|
|
260
287
|
include Serializable
|
|
261
288
|
|
|
262
289
|
def self.from_h(hash)
|
|
@@ -264,6 +291,7 @@ module Cronwatch
|
|
|
264
291
|
|
|
265
292
|
pending = Naming.fetch(hash, "pendingRecovery")
|
|
266
293
|
undelivered = Naming.fetch(hash, "undelivered")
|
|
294
|
+
sending = Naming.fetch(hash, "sending")
|
|
267
295
|
new(
|
|
268
296
|
job: Naming.fetch(hash, "job"),
|
|
269
297
|
open: (Naming.fetch(hash, "open") || {}).each_with_object({}) { |(k, v), out| out[k.to_sym] = v },
|
|
@@ -273,12 +301,34 @@ module Cronwatch
|
|
|
273
301
|
pending_recovery: pending&.map(&:to_sym),
|
|
274
302
|
undelivered: undelivered&.map { |a| Alert.from_h(a) },
|
|
275
303
|
version: Naming.fetch(hash, "version"),
|
|
304
|
+
sending: sending.is_a?(Array) ? sending.map { |entry| JobState.sending_entry(entry) } : sending,
|
|
276
305
|
)
|
|
277
306
|
end
|
|
278
307
|
|
|
308
|
+
# One entry of `sending` as read: its alert parsed when it is a Hash
|
|
309
|
+
# that parses, anything else kept as it came. `until` and `alert` come
|
|
310
|
+
# first, in the order the SDK writes them, since a store on Postgres
|
|
311
|
+
# jsonb gives an object's keys back in an order of its own.
|
|
312
|
+
def self.sending_entry(entry)
|
|
313
|
+
return entry unless entry.is_a?(Hash)
|
|
314
|
+
|
|
315
|
+
read = entry.to_h do |key, value|
|
|
316
|
+
next [key.to_s, value] unless key.to_s == "alert" && value.is_a?(Hash)
|
|
317
|
+
|
|
318
|
+
parsed = begin
|
|
319
|
+
Alert.from_h(value)
|
|
320
|
+
rescue StandardError
|
|
321
|
+
value
|
|
322
|
+
end
|
|
323
|
+
["alert", parsed]
|
|
324
|
+
end
|
|
325
|
+
%w[until alert].select { |key| read.key?(key) }.to_h { |key| [key, read[key]] }.merge(read)
|
|
326
|
+
end
|
|
327
|
+
|
|
279
328
|
# pendingRecovery, undelivered and version are left out when unset, as in
|
|
280
|
-
# state written before they existed. The version comes
|
|
281
|
-
# SDK's spread of a normalized state puts it
|
|
329
|
+
# state written before they existed. The version comes after them, where
|
|
330
|
+
# the SDK's spread of a normalized state puts it, and `sending` last, and
|
|
331
|
+
# only while it holds an entry.
|
|
282
332
|
def to_h
|
|
283
333
|
out = {
|
|
284
334
|
"job" => job, "open" => (open || {}).transform_keys(&:to_s), "consecutiveFailures" => consecutive_failures,
|
|
@@ -287,6 +337,11 @@ module Cronwatch
|
|
|
287
337
|
out["pendingRecovery"] = pending_recovery.map(&:to_s) unless pending_recovery.nil?
|
|
288
338
|
out["undelivered"] = undelivered.map(&:to_h) unless undelivered.nil?
|
|
289
339
|
out["version"] = version unless version.nil?
|
|
340
|
+
if sending.is_a?(Array) && !sending.empty?
|
|
341
|
+
out["sending"] = sending.map do |entry|
|
|
342
|
+
entry.is_a?(Hash) ? entry.transform_values { |v| v.is_a?(Alert) ? v.to_h : v } : entry
|
|
343
|
+
end
|
|
344
|
+
end
|
|
290
345
|
out
|
|
291
346
|
end
|
|
292
347
|
end
|
data/lib/cronwatch/version.rb
CHANGED
data/lib/cronwatch/web/app.rb
CHANGED
|
@@ -20,9 +20,11 @@ module Cronwatch
|
|
|
20
20
|
# or open the dashboard once with `?token=<token>` and a cookie is set.
|
|
21
21
|
# Defaults to ENV["CRONWATCH_TOKEN"]; an empty string counts as unset.
|
|
22
22
|
# With no token while Cronwatch::Environment is development or
|
|
23
|
-
# test
|
|
24
|
-
#
|
|
25
|
-
#
|
|
23
|
+
# test (Environment.stated_development?: under Puma, Unicorn,
|
|
24
|
+
# Thin or rackup, which set RACK_ENV=development by default,
|
|
25
|
+
# RACK_ENV alone does not count), the app makes a random one and
|
|
26
|
+
# prints a sign-in link to stdout on its first request; with no
|
|
27
|
+
# token otherwise it answers 503. Pass `token: nil` to opt out and serve it open everywhere,
|
|
26
28
|
# for example behind your own auth. /api/check also accepts the
|
|
27
29
|
# client's cron_secret as a bearer, for a platform cron.
|
|
28
30
|
# base_path: where the app is mounted, so links resolve. Defaults to the
|
|
@@ -79,7 +81,7 @@ module Cronwatch
|
|
|
79
81
|
# (proxies, tunnels and a server bound to every interface all look
|
|
80
82
|
# alike), so development gets a token too: made here, and shown only in
|
|
81
83
|
# the server log.
|
|
82
|
-
@generated = @token.nil? && !@opted_out &&
|
|
84
|
+
@generated = @token.nil? && !@opted_out && Environment.stated_development?
|
|
83
85
|
@token = Web.development_token if @generated
|
|
84
86
|
@announced = false
|
|
85
87
|
@announce_lock = Mutex.new
|
data/lib/cronwatch/web/html.rb
CHANGED
|
@@ -242,7 +242,7 @@ module Cronwatch
|
|
|
242
242
|
|
|
243
243
|
# Object.entries: integer-like keys first, ascending, then the rest in insertion order.
|
|
244
244
|
def entries(hash)
|
|
245
|
-
return []
|
|
245
|
+
return [] unless hash.is_a?(Hash)
|
|
246
246
|
|
|
247
247
|
JS.object_keys(hash).map { |k| [k.to_s, hash[k]] }
|
|
248
248
|
end
|
|
@@ -456,7 +456,8 @@ module Cronwatch
|
|
|
456
456
|
truthy?(run.error) ? %(<details class="out error" open><summary>error</summary><pre>#{h(run.error)}</pre></details>) : "",
|
|
457
457
|
truthy?(run.output) ? %(<details class="out"#{run.status.to_s == "ok" ? "" : " open"}><summary>output</summary><pre>#{h(run.output)}</pre></details>) : "",
|
|
458
458
|
].join
|
|
459
|
-
|
|
459
|
+
# A foreign row may hold a metric that is no finite number (null, text); it is left out.
|
|
460
|
+
metrics = entries(run.metrics).select { |_, v| v.is_a?(Numeric) && v.real? && JS.finite?(v) }.map do |k, v|
|
|
460
461
|
%(<span><span class="k">#{h(k)}</span> #{h(JS.integer?(v) ? v : to_fixed(v, 4))}</span>)
|
|
461
462
|
end.join
|
|
462
463
|
took = run.duration_ms.nil? ? %(<span class="muted">running</span>) : h(Duration.format(run.duration_ms))
|