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.
@@ -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.
@@ -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") || {}).transform_keys(&:to_s),
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 || {}).transform_keys(&:to_s), "trigger" => trigger,
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 last, where the
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
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Cronwatch
4
4
  # Kept in step with @cronwatch/sdk.
5
- VERSION = "0.8.0"
5
+ VERSION = "0.10.0"
6
6
  end
@@ -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, the app makes a random one and prints a sign-in link to
24
- # stdout on its first request; with no token otherwise it answers
25
- # 503. Pass `token: nil` to opt out and serve it open everywhere,
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 && Client.development?
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
@@ -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 [] if hash.nil?
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
- metrics = entries(run.metrics).map do |k, v|
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. The templates live in this
15
- # file so the gem ships nothing but Ruby.
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 migration, in an app with several"
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
- prefix == Cronwatch::Stores::ActiveRecord::DEFAULT_PREFIX ? "" : "(prefix: #{prefix.inspect})"
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
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: cronwatch
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.0
4
+ version: 0.10.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jon C. Phillips