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/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))
|
|
@@ -11,8 +11,11 @@ module Cronwatch
|
|
|
11
11
|
# Writes a migration for the three tables the ActiveRecord store uses
|
|
12
12
|
# (created with the SDK's own DDL, so a Node process can share them) and
|
|
13
13
|
# config/initializers/cronwatch.rb, then prints how to schedule
|
|
14
|
-
# Cronwatch::CheckJob and mount the dashboard.
|
|
15
|
-
#
|
|
14
|
+
# Cronwatch::CheckJob and mount the dashboard. With --database, the
|
|
15
|
+
# migration goes to that database and app/models/cronwatch_record.rb, an
|
|
16
|
+
# abstract class connected to it, is the store's connection_class, so the
|
|
17
|
+
# store reads the tables where the migration made them. The templates
|
|
18
|
+
# live in this file so the gem ships nothing but Ruby.
|
|
16
19
|
class InstallGenerator < ::Rails::Generators::Base
|
|
17
20
|
include ::ActiveRecord::Generators::Migration
|
|
18
21
|
|
|
@@ -21,7 +24,10 @@ module Cronwatch
|
|
|
21
24
|
class_option :prefix, type: :string, default: Cronwatch::Stores::ActiveRecord::DEFAULT_PREFIX,
|
|
22
25
|
desc: "Table name prefix: lowercase letters, digits and underscores"
|
|
23
26
|
class_option :database, type: :string, aliases: %i[--db],
|
|
24
|
-
desc: "The database for the
|
|
27
|
+
desc: "The database for the tables, in an app with several (from config/database.yml)"
|
|
28
|
+
|
|
29
|
+
# The abstract class --database writes, which the store connects through.
|
|
30
|
+
RECORD_CLASS = "CronwatchRecord"
|
|
25
31
|
|
|
26
32
|
def check_prefix
|
|
27
33
|
Cronwatch::Stores::ActiveRecord.table_prefix(options[:prefix])
|
|
@@ -41,6 +47,12 @@ module Cronwatch
|
|
|
41
47
|
create_file File.join(dir, "#{number}_#{migration_name}.rb"), migration
|
|
42
48
|
end
|
|
43
49
|
|
|
50
|
+
def create_connection_class
|
|
51
|
+
return unless database_name
|
|
52
|
+
|
|
53
|
+
create_file "app/models/cronwatch_record.rb", connection_class
|
|
54
|
+
end
|
|
55
|
+
|
|
44
56
|
def create_initializer
|
|
45
57
|
create_file "config/initializers/cronwatch.rb", initializer
|
|
46
58
|
end
|
|
@@ -60,9 +72,33 @@ module Cronwatch
|
|
|
60
72
|
"create_cronwatch_#{prefix.squeeze("_").delete_prefix("_").delete_suffix("_")}_tables"
|
|
61
73
|
end
|
|
62
74
|
|
|
75
|
+
# The --database name, or nil.
|
|
76
|
+
def database_name
|
|
77
|
+
name = options[:database].to_s
|
|
78
|
+
name.empty? ? nil : name
|
|
79
|
+
end
|
|
80
|
+
|
|
63
81
|
def store_args
|
|
64
82
|
prefix = options[:prefix]
|
|
65
|
-
|
|
83
|
+
args = []
|
|
84
|
+
args << "prefix: #{prefix.inspect}" unless prefix == Cronwatch::Stores::ActiveRecord::DEFAULT_PREFIX
|
|
85
|
+
# Named, not referenced, so the initializer does not load an app class at boot.
|
|
86
|
+
args << "connection_class: #{RECORD_CLASS.inspect}" if database_name
|
|
87
|
+
args.empty? ? "" : "(#{args.join(", ")})"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def connection_class
|
|
91
|
+
<<~RUBY
|
|
92
|
+
# frozen_string_literal: true
|
|
93
|
+
|
|
94
|
+
# The #{database_name} database, where CronWatch keeps its tables. The store in
|
|
95
|
+
# config/initializers/cronwatch.rb connects through this class.
|
|
96
|
+
class #{RECORD_CLASS} < ActiveRecord::Base
|
|
97
|
+
self.abstract_class = true
|
|
98
|
+
|
|
99
|
+
connects_to database: { writing: :#{database_name} }
|
|
100
|
+
end
|
|
101
|
+
RUBY
|
|
66
102
|
end
|
|
67
103
|
|
|
68
104
|
def migration
|
|
@@ -102,7 +138,7 @@ module Cronwatch
|
|
|
102
138
|
#
|
|
103
139
|
# and run Cronwatch::CheckJob every few minutes to catch the runs that never happen.
|
|
104
140
|
Cronwatch.configure do |c|
|
|
105
|
-
# Jobs, runs and alert state, in this app's database.
|
|
141
|
+
# Jobs, runs and alert state, in #{database_name ? "the #{database_name} database (see app/models/cronwatch_record.rb)" : "this app's database"}.
|
|
106
142
|
c.store = Cronwatch::Stores::ActiveRecord.new#{store_args}
|
|
107
143
|
|
|
108
144
|
# Where alerts go. With none set, they are written to standard error.
|
|
@@ -127,14 +163,22 @@ module Cronwatch
|
|
|
127
163
|
RUBY
|
|
128
164
|
end
|
|
129
165
|
|
|
166
|
+
# What the next steps say about --database after the migrate line, or "".
|
|
167
|
+
def database_step
|
|
168
|
+
return "" unless database_name
|
|
169
|
+
|
|
170
|
+
"\n\n The store connects through #{RECORD_CLASS} (app/models/cronwatch_record.rb),\n " \
|
|
171
|
+
"which connects_to the #{database_name} database named in config/database.yml."
|
|
172
|
+
end
|
|
173
|
+
|
|
130
174
|
def next_steps
|
|
131
175
|
<<~TEXT
|
|
132
176
|
|
|
133
177
|
CronWatch is installed. Next:
|
|
134
178
|
|
|
135
|
-
1. Create the tables:
|
|
179
|
+
1. Create the tables#{database_name ? " in the #{database_name} database" : ""}:
|
|
136
180
|
|
|
137
|
-
bin/rails db:migrate
|
|
181
|
+
bin/rails db:migrate#{database_step}
|
|
138
182
|
|
|
139
183
|
2. Monitor a job:
|
|
140
184
|
|