cronwatch 0.10.0 → 0.11.1
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 +18 -9
- data/lib/cronwatch/alerts/datadog.rb +1 -0
- data/lib/cronwatch/alerts/discord.rb +22 -3
- data/lib/cronwatch/alerts/email.rb +2 -0
- data/lib/cronwatch/alerts/honeybadger.rb +1 -0
- data/lib/cronwatch/alerts/postmark.rb +1 -0
- data/lib/cronwatch/alerts/provider.rb +2 -0
- data/lib/cronwatch/alerts/resend.rb +1 -0
- data/lib/cronwatch/alerts/rollbar.rb +1 -0
- data/lib/cronwatch/alerts/sentry.rb +1 -0
- data/lib/cronwatch/alerts/sigv4.rb +2 -0
- data/lib/cronwatch/alerts/slack.rb +1 -0
- data/lib/cronwatch/alerts/twilio.rb +1 -0
- data/lib/cronwatch/alerts/webhook.rb +18 -5
- data/lib/cronwatch/client.rb +66 -17
- data/lib/cronwatch/cron_pattern.rb +17 -8
- data/lib/cronwatch/deprecation.rb +18 -0
- data/lib/cronwatch/duration.rb +2 -0
- data/lib/cronwatch/environment.rb +93 -17
- data/lib/cronwatch/evaluate.rb +41 -10
- data/lib/cronwatch/flight.rb +2 -0
- data/lib/cronwatch/format.rb +1 -0
- data/lib/cronwatch/http.rb +21 -5
- data/lib/cronwatch/job.rb +2 -0
- data/lib/cronwatch/js.rb +74 -1
- data/lib/cronwatch/monitored.rb +6 -1
- data/lib/cronwatch/output.rb +3 -0
- data/lib/cronwatch/pg_cron.rb +22 -3
- data/lib/cronwatch/rails/active_job.rb +2 -2
- data/lib/cronwatch/schedule.rb +25 -1
- data/lib/cronwatch/scheduler.rb +6 -1
- data/lib/cronwatch/serialize.rb +1 -0
- data/lib/cronwatch/stats.rb +1 -0
- data/lib/cronwatch/stores/active_record.rb +36 -18
- data/lib/cronwatch/ticker.rb +2 -0
- data/lib/cronwatch/triage/anthropic.rb +2 -0
- data/lib/cronwatch/types.rb +148 -31
- data/lib/cronwatch/version.rb +1 -1
- data/lib/cronwatch/walker.rb +2 -0
- data/lib/cronwatch/web/app.rb +108 -33
- data/lib/cronwatch/web/html.rb +10 -6
- data/lib/cronwatch/web/origin.rb +2 -0
- data/lib/cronwatch/web/pwa.rb +20 -2
- data/lib/cronwatch/web/timeline.rb +2 -0
- data/lib/cronwatch/web.rb +1 -2
- data/lib/cronwatch/zone.rb +2 -0
- data/lib/cronwatch.rb +2 -0
- data/lib/generators/cronwatch/install/install_generator.rb +1 -1
- metadata +2 -1
data/lib/cronwatch/serialize.rb
CHANGED
data/lib/cronwatch/stats.rb
CHANGED
|
@@ -247,9 +247,10 @@ module Cronwatch
|
|
|
247
247
|
read(:running_runs, []).map { |row| row_to_run(row) }
|
|
248
248
|
end
|
|
249
249
|
|
|
250
|
+
# A state whose text does not parse, or is not an object, reads as none.
|
|
250
251
|
def get_state(job)
|
|
251
252
|
row = read(:get_state, [job]).first
|
|
252
|
-
row && JobState.from_h(
|
|
253
|
+
row && JobState.from_h(lenient_json(row["state"]))
|
|
253
254
|
end
|
|
254
255
|
|
|
255
256
|
def set_state(state)
|
|
@@ -347,7 +348,8 @@ module Cronwatch
|
|
|
347
348
|
# The version inside a state's JSON, as Evaluate.state_version reads
|
|
348
349
|
# it: a whole number from 0 to 2**53 - 1, else 0 (none, or a foreign
|
|
349
350
|
# row's 1.5 or "x", which must neither fail the statement nor refuse
|
|
350
|
-
# every write for good
|
|
351
|
+
# every write for good; on SQLite, also text that is not JSON). Each
|
|
352
|
+
# CASE tests the JSON type before any cast.
|
|
351
353
|
version = lambda do |column|
|
|
352
354
|
if pg
|
|
353
355
|
v = "(#{column}->>'version')::numeric"
|
|
@@ -355,7 +357,8 @@ module Cronwatch
|
|
|
355
357
|
"WHEN #{v} % 1 = 0 AND #{v} BETWEEN 0 AND 9007199254740991 THEN #{v}::bigint ELSE 0 END"
|
|
356
358
|
end
|
|
357
359
|
v = "json_extract(#{column}, '$.version')"
|
|
358
|
-
|
|
360
|
+
# Text that is not JSON at all (SQLite holds any) counts as 0 too, before json_type could fail on it.
|
|
361
|
+
"CASE WHEN NOT json_valid(#{column}) THEN 0 WHEN json_type(#{column}, '$.version') NOT IN ('integer', 'real') THEN 0 " \
|
|
359
362
|
"WHEN #{v} = CAST(#{v} AS INTEGER) AND #{v} BETWEEN 0 AND 9007199254740991 THEN CAST(#{v} AS INTEGER) ELSE 0 END"
|
|
360
363
|
end
|
|
361
364
|
sql = {
|
|
@@ -425,28 +428,43 @@ module Cronwatch
|
|
|
425
428
|
end
|
|
426
429
|
end
|
|
427
430
|
|
|
428
|
-
|
|
429
|
-
|
|
431
|
+
# Rows are read leniently: a foreign, hand-edited or damaged row (SQLite
|
|
432
|
+
# keeps whatever type it is given, in any column) must affect only its
|
|
433
|
+
# own job, never every read. JSON text that does not parse (or nests
|
|
434
|
+
# past JSON.parse's limit) reads as nil, which the client takes as no
|
|
435
|
+
# state, or as an unreadable definition it reports.
|
|
436
|
+
def lenient_json(value)
|
|
437
|
+
json(value)
|
|
438
|
+
rescue ::JSON::ParserError
|
|
439
|
+
nil
|
|
430
440
|
end
|
|
431
441
|
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
442
|
+
# A time that must be there: one that is not a finite number reads as 0.
|
|
443
|
+
def time(value)
|
|
444
|
+
JS.finite_number(value) || 0
|
|
435
445
|
end
|
|
436
446
|
|
|
437
|
-
# A
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
447
|
+
# A time or duration that may be absent: one that is not a finite number reads as nil.
|
|
448
|
+
def maybe_time(value)
|
|
449
|
+
JS.finite_number(value)
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
def row_to_job(row)
|
|
453
|
+
StoredJob.new(name: row["name"], definition: JobDefinition.from_h(lenient_json(row["definition"]), row["name"]),
|
|
454
|
+
created_at: time(row["created_at"]), updated_at: time(row["updated_at"]))
|
|
443
455
|
end
|
|
444
456
|
|
|
457
|
+
# A start that is not a finite number reads as 0, a finish or duration
|
|
458
|
+
# as nil; an error or output that is not text as nil; metrics that do
|
|
459
|
+
# not parse to an object as {}; a trigger that is not text as "run".
|
|
445
460
|
def row_to_run(row)
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
461
|
+
error = row["error"]
|
|
462
|
+
output = row["output"]
|
|
463
|
+
trigger = row["trigger"]
|
|
464
|
+
Run.new(id: row["id"], job: row["job"], status: Run.status_from(row["status"]), started_at: time(row["started_at"]),
|
|
465
|
+
finished_at: maybe_time(row["finished_at"]), duration_ms: maybe_time(row["duration_ms"]),
|
|
466
|
+
error: error.is_a?(String) ? error : nil, output: output.is_a?(String) ? output : nil,
|
|
467
|
+
metrics: Run.metrics_from(lenient_json(row["metrics"])), trigger: trigger.is_a?(String) ? trigger : "run")
|
|
450
468
|
end
|
|
451
469
|
end
|
|
452
470
|
end
|
data/lib/cronwatch/ticker.rb
CHANGED
|
@@ -6,6 +6,8 @@ module Cronwatch
|
|
|
6
6
|
class Client
|
|
7
7
|
# Calls the block after `first` seconds, then every `interval` seconds
|
|
8
8
|
# counted from the start, in a background thread, until stopped.
|
|
9
|
+
#
|
|
10
|
+
# @api private
|
|
9
11
|
class Ticker
|
|
10
12
|
def initialize(interval, first, &tick)
|
|
11
13
|
@lock = Mutex.new
|
|
@@ -37,6 +37,8 @@ module Cronwatch
|
|
|
37
37
|
|
|
38
38
|
# JavaScript's /<\/?job_data/gi, spelled out: Ruby's /i folds more than ASCII.
|
|
39
39
|
TAG = %r{</?[Jj][Oo][Bb]_[Dd][Aa][Tt][Aa]}
|
|
40
|
+
private_constant :DEFAULT_EFFORT, :DEFAULT_MAX_TOKENS, :DEFAULT_MODEL, :FALLBACK_BETA, :REQUEST_TIMEOUT_MS,
|
|
41
|
+
:SYSTEM, :TAG
|
|
40
42
|
|
|
41
43
|
attr_reader :model, :effort, :max_tokens, :fallbacks, :context
|
|
42
44
|
|
data/lib/cronwatch/types.rb
CHANGED
|
@@ -7,6 +7,8 @@ module Cronwatch
|
|
|
7
7
|
# Ruby names are snake_case symbols; everything that leaves the process
|
|
8
8
|
# (store rows, JSON, webhook bodies) uses the SDK's camelCase names and
|
|
9
9
|
# string values. Each type's to_h is that JSON shape, and from_h reads it.
|
|
10
|
+
#
|
|
11
|
+
# @api private
|
|
10
12
|
module Naming
|
|
11
13
|
module_function
|
|
12
14
|
|
|
@@ -51,6 +53,8 @@ module Cronwatch
|
|
|
51
53
|
end
|
|
52
54
|
|
|
53
55
|
# Shared by the structs below.
|
|
56
|
+
#
|
|
57
|
+
# @api private
|
|
54
58
|
module Serializable
|
|
55
59
|
def as_json(*)
|
|
56
60
|
to_h
|
|
@@ -78,28 +82,41 @@ module Cronwatch
|
|
|
78
82
|
|
|
79
83
|
FIELDS.each_key { |field| define_method(field) { @fields[field] } }
|
|
80
84
|
|
|
81
|
-
|
|
82
|
-
UNREADABLE = {}.freeze
|
|
85
|
+
private_constant :BY_JSON, :FIELDS
|
|
83
86
|
|
|
84
87
|
def initialize(fields = {})
|
|
85
88
|
@fields = {}
|
|
86
89
|
fields.each { |k, v| @fields[k.is_a?(Symbol) ? k : k.to_s] = v }
|
|
87
90
|
@fields.freeze
|
|
88
|
-
@unreadable =
|
|
91
|
+
@unreadable = false
|
|
89
92
|
freeze
|
|
90
93
|
end
|
|
91
94
|
|
|
92
95
|
# A stored definition that is not a JSON object (null, a string, a
|
|
93
|
-
# number,
|
|
94
|
-
#
|
|
95
|
-
#
|
|
96
|
-
#
|
|
97
|
-
|
|
96
|
+
# number, an array, or text that does not parse, written by hand or by
|
|
97
|
+
# something else) reads as `{ name }` (`name` being the job's, when
|
|
98
|
+
# given) marked unreadable, so the one row does not stop every other job
|
|
99
|
+
# being listed; a check or a dashboard read reports that job and shows it
|
|
100
|
+
# as failing (see Client#evaluable). `tags` is kept only when it is a
|
|
101
|
+
# list of strings. Every other field is kept as stored.
|
|
102
|
+
def self.from_h(hash, name = nil)
|
|
98
103
|
return hash if hash.is_a?(JobDefinition)
|
|
99
|
-
return
|
|
104
|
+
return unreadable(name) unless hash.is_a?(Hash)
|
|
105
|
+
|
|
106
|
+
fields = hash.each_with_object({}) { |(k, v), out| out[BY_JSON[k.to_s] || k.to_s] = v }
|
|
107
|
+
tags = fields[:tags]
|
|
108
|
+
fields.delete(:tags) if fields.key?(:tags) && !(tags.is_a?(Array) && tags.all?(String))
|
|
109
|
+
new(fields)
|
|
110
|
+
end
|
|
100
111
|
|
|
101
|
-
|
|
112
|
+
# `{ name }`, marked unreadable. See from_h.
|
|
113
|
+
def self.unreadable(name)
|
|
114
|
+
definition = allocate
|
|
115
|
+
definition.instance_variable_set(:@fields, (name.nil? ? {} : { name: name }).freeze)
|
|
116
|
+
definition.instance_variable_set(:@unreadable, true)
|
|
117
|
+
definition.freeze
|
|
102
118
|
end
|
|
119
|
+
private_class_method :unreadable
|
|
103
120
|
|
|
104
121
|
def [](field)
|
|
105
122
|
@fields[field]
|
|
@@ -161,7 +178,7 @@ module Cronwatch
|
|
|
161
178
|
new(
|
|
162
179
|
id: Naming.fetch(hash, "id"),
|
|
163
180
|
job: Naming.fetch(hash, "job"),
|
|
164
|
-
status: Naming.fetch(hash, "status")
|
|
181
|
+
status: Run.status_from(Naming.fetch(hash, "status")),
|
|
165
182
|
started_at: Naming.fetch(hash, "startedAt"),
|
|
166
183
|
finished_at: Naming.fetch(hash, "finishedAt"),
|
|
167
184
|
duration_ms: Naming.fetch(hash, "durationMs"),
|
|
@@ -174,12 +191,19 @@ module Cronwatch
|
|
|
174
191
|
|
|
175
192
|
def to_h
|
|
176
193
|
{
|
|
177
|
-
"id" => id, "job" => job, "status" => status.
|
|
194
|
+
"id" => id, "job" => job, "status" => status.is_a?(Symbol) ? status.to_s : status,
|
|
195
|
+
"startedAt" => started_at, "finishedAt" => finished_at,
|
|
178
196
|
"durationMs" => duration_ms, "error" => error, "output" => output,
|
|
179
197
|
"metrics" => Run.metrics_from(metrics), "trigger" => trigger,
|
|
180
198
|
}
|
|
181
199
|
end
|
|
182
200
|
|
|
201
|
+
# A status as read: text as a Symbol, anything else (a foreign or
|
|
202
|
+
# damaged row's) kept as it came, which matches no known status.
|
|
203
|
+
def self.status_from(value)
|
|
204
|
+
value.is_a?(String) ? value.to_sym : value
|
|
205
|
+
end
|
|
206
|
+
|
|
183
207
|
# Stored metrics as a Hash with string keys. Anything else (a string or
|
|
184
208
|
# an array a foreign or corrupted row holds) reads as none.
|
|
185
209
|
def self.metrics_from(value)
|
|
@@ -196,9 +220,10 @@ module Cronwatch
|
|
|
196
220
|
def self.from_h(hash)
|
|
197
221
|
return hash if hash.is_a?(StoredJob)
|
|
198
222
|
|
|
223
|
+
name = Naming.fetch(hash, "name")
|
|
199
224
|
new(
|
|
200
|
-
name:
|
|
201
|
-
definition: JobDefinition.from_h(Naming.fetch(hash, "definition")
|
|
225
|
+
name: name,
|
|
226
|
+
definition: JobDefinition.from_h(Naming.fetch(hash, "definition"), name),
|
|
202
227
|
created_at: Naming.fetch(hash, "createdAt"),
|
|
203
228
|
updated_at: Naming.fetch(hash, "updatedAt"),
|
|
204
229
|
)
|
|
@@ -211,6 +236,8 @@ module Cronwatch
|
|
|
211
236
|
|
|
212
237
|
# An alert before it has a title and message. See Format.compose_alert.
|
|
213
238
|
# `details` is a hash with snake_case symbol keys; its JSON is camelCase.
|
|
239
|
+
#
|
|
240
|
+
# @api private
|
|
214
241
|
AlertDraft = Struct.new(:type, :run, :details, keyword_init: true) do
|
|
215
242
|
include Serializable
|
|
216
243
|
|
|
@@ -241,65 +268,145 @@ module Cronwatch
|
|
|
241
268
|
@triage_tried = true
|
|
242
269
|
end
|
|
243
270
|
|
|
271
|
+
# Read leniently, since a queued alert a foreign writer left malformed
|
|
272
|
+
# must affect only its own job: a field of the wrong type is kept as it
|
|
273
|
+
# came (a `type` or `run` that is not what it should be, `details` that
|
|
274
|
+
# are not an object, an `at` that is not a number), and the retry drops
|
|
275
|
+
# such an alert as stale (Evaluate.stale_alert?).
|
|
244
276
|
def self.from_h(hash)
|
|
245
277
|
return hash if hash.is_a?(Alert)
|
|
246
278
|
|
|
247
279
|
run = Naming.fetch(hash, "run")
|
|
248
|
-
|
|
249
|
-
details
|
|
250
|
-
|
|
280
|
+
written = Naming.fetch(hash, "details") || {}
|
|
281
|
+
details = Naming.from_json_value(written)
|
|
282
|
+
if details.is_a?(Hash)
|
|
283
|
+
details[:after] = details[:after].map { |c| c.is_a?(String) ? c.to_sym : c } if details[:after].is_a?(Array)
|
|
284
|
+
details[:reason] = details[:reason].to_sym if details[:reason].is_a?(String)
|
|
285
|
+
end
|
|
286
|
+
type = Naming.fetch(hash, "type")
|
|
287
|
+
definition = Naming.fetch(hash, "definition")
|
|
251
288
|
alert = new(
|
|
252
|
-
type:
|
|
253
|
-
run: run
|
|
289
|
+
type: type.is_a?(String) ? type.to_sym : type,
|
|
290
|
+
run: run.is_a?(Hash) ? Run.from_h(run) : run,
|
|
254
291
|
details: details,
|
|
255
292
|
job: Naming.fetch(hash, "job"),
|
|
256
|
-
definition: JobDefinition.from_h(
|
|
293
|
+
definition: definition.nil? || definition.is_a?(Hash) ? JobDefinition.from_h(definition || {}) : definition,
|
|
257
294
|
title: Naming.fetch(hash, "title"),
|
|
258
295
|
message: Naming.fetch(hash, "message"),
|
|
259
296
|
at: Naming.fetch(hash, "at"),
|
|
260
297
|
)
|
|
261
298
|
alert.triage_result = Naming.fetch(hash, "triage") if Naming.present?(hash, "triage")
|
|
299
|
+
alert.keep_as_written(hash, written, details)
|
|
262
300
|
alert
|
|
263
301
|
end
|
|
264
302
|
|
|
303
|
+
# What a stored alert held that its members do not say again: the
|
|
304
|
+
# fields this version does not know (a newer release may add one), its
|
|
305
|
+
# details as written, since snake_case and back would turn a key spelled
|
|
306
|
+
# `a_b` into `aB`, and which known fields it left out. to_h writes the
|
|
307
|
+
# first two back, the details only while they are unchanged, and leaves
|
|
308
|
+
# out a field that was absent while it still holds what its absence
|
|
309
|
+
# read as, so a partial alert (a foreign row's) is written as it came,
|
|
310
|
+
# in the order it came. An alert with every field the SDK writes has
|
|
311
|
+
# them written back in the SDK's order, then the fields it does not
|
|
312
|
+
# know, since a store on Postgres jsonb gives an object's keys back in
|
|
313
|
+
# an order of its own (as JobState.sending_entry does for an entry).
|
|
314
|
+
#
|
|
315
|
+
# @api private
|
|
316
|
+
def keep_as_written(hash, written, details)
|
|
317
|
+
extra = hash.each_with_object({}) { |(k, v), out| out[k.to_s] = v unless Alert::KNOWN.include?(k.to_s) }
|
|
318
|
+
@extra = extra.empty? ? nil : extra
|
|
319
|
+
@written_details = [Marshal.load(Marshal.dump(details)), Alert.string_keys(written)] if written.is_a?(Hash)
|
|
320
|
+
absent = (Alert::KNOWN - ["triage"]).reject { |key| Naming.present?(hash, key) }
|
|
321
|
+
@absent = absent.empty? ? nil : to_h.slice(*absent)
|
|
322
|
+
order = hash.keys.map(&:to_s)
|
|
323
|
+
@order = @absent && order.uniq == order ? order : nil
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
# @api private
|
|
327
|
+
def self.string_keys(value)
|
|
328
|
+
case value
|
|
329
|
+
when Hash then value.to_h { |k, v| [k.to_s, string_keys(v)] }
|
|
330
|
+
when Array then value.map { |v| string_keys(v) }
|
|
331
|
+
else value
|
|
332
|
+
end
|
|
333
|
+
end
|
|
334
|
+
|
|
265
335
|
def to_h
|
|
266
336
|
out = {
|
|
267
|
-
"type" => type.to_s, "run" => run
|
|
268
|
-
"
|
|
337
|
+
"type" => type.is_a?(Symbol) ? type.to_s : type, "run" => run.is_a?(Run) ? run.to_h : run,
|
|
338
|
+
"details" => details_json, "job" => job,
|
|
339
|
+
"definition" => definition.is_a?(JobDefinition) ? definition.to_h : definition,
|
|
269
340
|
"title" => title, "message" => message, "at" => at,
|
|
270
341
|
}
|
|
342
|
+
@absent&.each { |key, value| out.delete(key) if out[key] == value }
|
|
271
343
|
out["triage"] = triage if triage_tried?
|
|
272
|
-
out
|
|
344
|
+
@extra&.each { |key, value| out[key] = value unless out.key?(key) || Alert::KNOWN.include?(key) }
|
|
345
|
+
return out if @order.nil? || @order == out.keys
|
|
346
|
+
|
|
347
|
+
# The keys in the order they were read, as the object a store held
|
|
348
|
+
# keeps them; any added since go after.
|
|
349
|
+
written = @order.select { |key| out.key?(key) }
|
|
350
|
+
(written + (out.keys - written)).to_h { |key| [key, out[key]] }
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
private
|
|
354
|
+
|
|
355
|
+
def details_json
|
|
356
|
+
return @written_details[1] if @written_details && @written_details[0] == details
|
|
357
|
+
|
|
358
|
+
Naming.to_json_value(details)
|
|
273
359
|
end
|
|
274
360
|
end
|
|
275
361
|
|
|
362
|
+
# The fields this version reads from a stored alert; the rest are kept as they came.
|
|
363
|
+
# @api private
|
|
364
|
+
Alert::KNOWN = %w[type run details job definition title message at triage].freeze
|
|
365
|
+
|
|
276
366
|
# `version` goes up by one on every write, so a store can refuse a write
|
|
277
367
|
# made from a stale read (see Stores::Memory#compare_and_set_state). Absent
|
|
278
368
|
# (nil) counts as 0.
|
|
279
369
|
#
|
|
370
|
+
# from_h reads a state as stored, keeping each field as it came where it
|
|
371
|
+
# has the wrong type (Evaluate.normalize_state then reads it leniently),
|
|
372
|
+
# so a foreign, hand-edited or damaged row is written back unchanged by a
|
|
373
|
+
# read that changes nothing, and affects only its own job. A stored state
|
|
374
|
+
# that is not a JSON object reads as none (nil).
|
|
375
|
+
#
|
|
280
376
|
# `sending` is the outbox (see Evaluate.hold_alerts): entries of
|
|
281
377
|
# { "until" => epoch ms, "alert" => Alert }, read leniently, since an entry
|
|
282
378
|
# a foreign writer left malformed must not make the whole state unreadable:
|
|
283
379
|
# an entry that is not a Hash, or whose alert is not one, is kept as it
|
|
284
380
|
# came. nil when there is none; it is never written empty.
|
|
381
|
+
#
|
|
382
|
+
# `extra` holds the fields this version does not know, written by a newer
|
|
383
|
+
# one, as the JSON they came as (string keys): every write carries them
|
|
384
|
+
# back unchanged, after the known fields, so a process sharing the store
|
|
385
|
+
# with a newer release never erases what that release keeps there. nil
|
|
386
|
+
# when there are none. An open condition this version does not know stays
|
|
387
|
+
# in `open` the same way, under its own name.
|
|
285
388
|
JobState = Struct.new(:job, :open, :consecutive_failures, :silenced_until, :last_alert_at, :pending_recovery,
|
|
286
|
-
:undelivered, :version, :sending, keyword_init: true) do
|
|
389
|
+
:undelivered, :version, :sending, :extra, keyword_init: true) do
|
|
287
390
|
include Serializable
|
|
288
391
|
|
|
289
392
|
def self.from_h(hash)
|
|
290
393
|
return hash if hash.is_a?(JobState)
|
|
394
|
+
return nil unless hash.is_a?(Hash)
|
|
291
395
|
|
|
396
|
+
open = Naming.fetch(hash, "open") || {}
|
|
292
397
|
pending = Naming.fetch(hash, "pendingRecovery")
|
|
293
398
|
undelivered = Naming.fetch(hash, "undelivered")
|
|
294
399
|
sending = Naming.fetch(hash, "sending")
|
|
400
|
+
extra = hash.each_with_object({}) { |(k, v), out| out[k.to_s] = v unless JobState::KNOWN.include?(k.to_s) }
|
|
295
401
|
new(
|
|
402
|
+
extra: extra.empty? ? nil : extra,
|
|
296
403
|
job: Naming.fetch(hash, "job"),
|
|
297
|
-
open:
|
|
404
|
+
open: open.is_a?(Hash) ? open.to_h { |k, v| [k.to_sym, v] } : open,
|
|
298
405
|
consecutive_failures: Naming.fetch(hash, "consecutiveFailures", 0),
|
|
299
406
|
silenced_until: Naming.fetch(hash, "silencedUntil"),
|
|
300
407
|
last_alert_at: Naming.fetch(hash, "lastAlertAt"),
|
|
301
|
-
pending_recovery: pending
|
|
302
|
-
undelivered: undelivered
|
|
408
|
+
pending_recovery: pending.is_a?(Array) ? pending.map { |c| c.is_a?(String) ? c.to_sym : c } : pending,
|
|
409
|
+
undelivered: undelivered.is_a?(Array) ? undelivered.map { |a| a.is_a?(Hash) ? Alert.from_h(a) : a } : undelivered,
|
|
303
410
|
version: Naming.fetch(hash, "version"),
|
|
304
411
|
sending: sending.is_a?(Array) ? sending.map { |entry| JobState.sending_entry(entry) } : sending,
|
|
305
412
|
)
|
|
@@ -331,21 +438,31 @@ module Cronwatch
|
|
|
331
438
|
# only while it holds an entry.
|
|
332
439
|
def to_h
|
|
333
440
|
out = {
|
|
334
|
-
"job" => job, "open" => (
|
|
335
|
-
"silencedUntil" => silenced_until, "lastAlertAt" => last_alert_at,
|
|
441
|
+
"job" => job, "open" => open.is_a?(Hash) ? open.transform_keys(&:to_s) : (open || {}),
|
|
442
|
+
"consecutiveFailures" => consecutive_failures, "silencedUntil" => silenced_until, "lastAlertAt" => last_alert_at,
|
|
336
443
|
}
|
|
337
|
-
|
|
338
|
-
|
|
444
|
+
unless pending_recovery.nil?
|
|
445
|
+
out["pendingRecovery"] =
|
|
446
|
+
pending_recovery.is_a?(Array) ? pending_recovery.map { |c| c.is_a?(Symbol) ? c.to_s : c } : pending_recovery
|
|
447
|
+
end
|
|
448
|
+
unless undelivered.nil?
|
|
449
|
+
out["undelivered"] = undelivered.is_a?(Array) ? undelivered.map { |a| a.is_a?(Alert) ? a.to_h : a } : undelivered
|
|
450
|
+
end
|
|
339
451
|
out["version"] = version unless version.nil?
|
|
340
452
|
if sending.is_a?(Array) && !sending.empty?
|
|
341
453
|
out["sending"] = sending.map do |entry|
|
|
342
454
|
entry.is_a?(Hash) ? entry.transform_values { |v| v.is_a?(Alert) ? v.to_h : v } : entry
|
|
343
455
|
end
|
|
344
456
|
end
|
|
457
|
+
extra&.each { |key, value| out[key] = value unless out.key?(key) || JobState::KNOWN.include?(key) }
|
|
345
458
|
out
|
|
346
459
|
end
|
|
347
460
|
end
|
|
348
461
|
|
|
462
|
+
# The fields this version reads from a stored state; the rest are kept in `extra`.
|
|
463
|
+
# @api private
|
|
464
|
+
JobState::KNOWN = %w[job open consecutiveFailures silencedUntil lastAlertAt pendingRecovery undelivered version sending].freeze
|
|
465
|
+
|
|
349
466
|
JobStats = Struct.new(:runs, :ok_rate, :p50_ms, :p95_ms, keyword_init: true) do
|
|
350
467
|
include Serializable
|
|
351
468
|
|
data/lib/cronwatch/version.rb
CHANGED
data/lib/cronwatch/walker.rb
CHANGED
|
@@ -6,6 +6,8 @@ module Cronwatch
|
|
|
6
6
|
# the next match, a field at a time, spilling into the next month or year
|
|
7
7
|
# as croner does (including its habit of stepping into a day the month
|
|
8
8
|
# does not have and letting the date roll over). Month is 0 based.
|
|
9
|
+
#
|
|
10
|
+
# @api private
|
|
9
11
|
class Walker
|
|
10
12
|
DAYS_IN_MONTH = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31].freeze
|
|
11
13
|
# [field, the field above it, offset from a field value to its pattern index]
|