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.
@@ -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
@@ -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 by an older version lacks the newer fields.
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
@@ -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.cap trims it exactly at the end.
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 : Output.cap(@lines.join("\n")) }
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
@@ -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
- "[earlier output trimmed]\n#{JS.tail16(text, CAP)}"
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.
@@ -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)`). sync returns the alerts
17
- # recording sent, or anything else for none.
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
@@ -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
- config = read
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
- return [] unless config.is_a?(Hash)
173
+ tasks(config)
174
+ end
156
175
 
157
- config.filter_map do |key, options|
158
- next unless options.is_a?(Hash) && options.key?(:schedule)
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
- Entry.new(source: self, key: key.to_s, class_name: present(options[:class]), command: present(options[:command]),
161
- schedule: options[:schedule], description: present(options[:description]), disabled: false)
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. Raises Error when it has none, or more than one.
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
- matches = found.flat_map(&:entries).select { |entry| !entry.disabled && same_class?(entry.class_name, name) }
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(json(row["definition"])),
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"])
@@ -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.9.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))