cronwatch 0.12.4 → 0.13.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 +18 -61
- data/lib/cronwatch/alerts/datadog.rb +1 -1
- data/lib/cronwatch/alerts/discord.rb +1 -1
- data/lib/cronwatch/alerts/email.rb +2 -2
- data/lib/cronwatch/alerts/provider.rb +21 -3
- data/lib/cronwatch/alerts/slack.rb +1 -1
- data/lib/cronwatch/alerts/webhook.rb +8 -1
- data/lib/cronwatch/client.rb +153 -46
- data/lib/cronwatch/duration.rb +2 -2
- data/lib/cronwatch/environment.rb +34 -23
- data/lib/cronwatch/evaluate.rb +72 -11
- data/lib/cronwatch/format.rb +9 -1
- data/lib/cronwatch/http.rb +18 -7
- data/lib/cronwatch/js.rb +3 -3
- data/lib/cronwatch/monitored.rb +3 -1
- data/lib/cronwatch/output.rb +6 -6
- data/lib/cronwatch/pg_cron.rb +7 -7
- data/lib/cronwatch/rails/active_job.rb +1 -1
- data/lib/cronwatch/rails/check_job.rb +1 -1
- data/lib/cronwatch/rails.rb +1 -1
- data/lib/cronwatch/run_handle.rb +2 -2
- data/lib/cronwatch/scheduler.rb +6 -6
- data/lib/cronwatch/sidekiq.rb +5 -3
- data/lib/cronwatch/stores/active_record.rb +5 -5
- data/lib/cronwatch/stores/memory.rb +1 -1
- data/lib/cronwatch/triage/anthropic.rb +3 -3
- data/lib/cronwatch/types.rb +11 -7
- data/lib/cronwatch/version.rb +1 -1
- data/lib/cronwatch/web/app.rb +149 -34
- data/lib/cronwatch/web/html.rb +2 -2
- data/lib/cronwatch/web/origin.rb +2 -2
- data/lib/cronwatch/web/pwa.rb +4 -4
- data/lib/cronwatch/web/timeline.rb +3 -3
- data/lib/cronwatch.rb +1 -1
- data/lib/generators/cronwatch/install/install_generator.rb +5 -5
- metadata +3 -3
data/lib/cronwatch/client.rb
CHANGED
|
@@ -43,7 +43,7 @@ module Cronwatch
|
|
|
43
43
|
# Run ids that start with this belong to the pg_cron source (Sources::PgCron).
|
|
44
44
|
RESERVED_RUN_ID_PREFIX = "pgcron:"
|
|
45
45
|
# The longest run id, in UTF-16 code units (JavaScript's string length):
|
|
46
|
-
# what start, resume and record_run take, and every store holds.
|
|
46
|
+
# what start, resume, and record_run take, and every store holds.
|
|
47
47
|
MAX_RUN_ID = 200
|
|
48
48
|
private_constant :CHANNEL_TIMEOUT_MS, :DEFAULT_OPTIONS, :FORK_LOCK, :HISTORY_MAX, :HISTORY_PAGE, :MAX_RUN_ID,
|
|
49
49
|
:PRUNE_INTERVAL_MS, :RESERVED_RUN_ID_PREFIX, :RETRY_BUDGET_MS, :SILENCE_OPTIONS, :STATE_ATTEMPTS,
|
|
@@ -77,19 +77,19 @@ module Cronwatch
|
|
|
77
77
|
|
|
78
78
|
attr_reader :store, :alerts, :triage, :cron_secret, :retention_ms, :defaults, :sources
|
|
79
79
|
|
|
80
|
-
# store: where jobs, runs and state live. Defaults to an in-memory store that forgets on restart.
|
|
80
|
+
# store: where jobs, runs, and state live. Defaults to an in-memory store that forgets on restart.
|
|
81
81
|
# alerts: where alerts go: objects with #call(alert) and #name. Defaults to the console.
|
|
82
82
|
# triage: a callable taking a TriageContext and returning a short diagnosis, added to every alert but recoveries.
|
|
83
83
|
# cron_secret: a second bearer Cronwatch::Web accepts for /api/check, for an outside cron. Defaults to
|
|
84
|
-
# ENV["CRON_SECRET"]; an empty string or one of
|
|
85
|
-
# variable, counts as unset. Pass nil for none. Anything but a String or nil (false, a
|
|
84
|
+
# ENV["CRON_SECRET"], trimmed of the whitespace around it; an empty string or one of
|
|
85
|
+
# only whitespace, given here or in the variable, counts as unset. Pass nil for none. Anything but a String or nil (false, a
|
|
86
86
|
# number, a Symbol) raises TypeError.
|
|
87
87
|
# retention: how long finished runs are kept. Default "30d".
|
|
88
|
-
# defaults: grace, timeout, timezone and failures_before_alert applied to every job unless it sets its own.
|
|
89
|
-
# redact: applied to every run's output and error before it is stored, shown or sent to an alert
|
|
88
|
+
# defaults: grace, timeout, timezone, and failures_before_alert applied to every job unless it sets its own.
|
|
89
|
+
# redact: applied to every run's output and error before it is stored, shown, or sent to an alert
|
|
90
90
|
# channel or triage. The default (Output.redact_secrets) blanks values that look like secrets
|
|
91
91
|
# (password=..., Authorization headers, URL credentials, bearer tokens, JWTs, PEM private
|
|
92
|
-
# keys, webhook URLs, AWS, GitHub, Slack, Stripe, Google and API key formats). Pass your own
|
|
92
|
+
# keys, webhook URLs, AWS, GitHub, Slack, Stripe, Google, and API key formats). Pass your own
|
|
93
93
|
# callable, or false to keep output exactly as logged. A callable that raises or returns
|
|
94
94
|
# something other than a String is reported to on_error ("redact") and the default is used.
|
|
95
95
|
# now: the clock, a callable returning epoch milliseconds. Tests use this.
|
|
@@ -137,6 +137,9 @@ module Cronwatch
|
|
|
137
137
|
@clock = now || -> { Process.clock_gettime(Process::CLOCK_REALTIME, :millisecond) }
|
|
138
138
|
@on_error = on_error || method(:default_on_error)
|
|
139
139
|
@definitions = {}
|
|
140
|
+
# Declarations forgotten here (forget), kept so that run and resume_run
|
|
141
|
+
# by name declare the job again as it was written, not bare.
|
|
142
|
+
@forgotten = {}
|
|
140
143
|
@synced = Set.new
|
|
141
144
|
@registry = Mutex.new
|
|
142
145
|
@ready = false
|
|
@@ -159,18 +162,36 @@ module Cronwatch
|
|
|
159
162
|
def job(name, **options)
|
|
160
163
|
name = name.to_s if name.is_a?(Symbol)
|
|
161
164
|
unless name.is_a?(String) && NAME_RE.match?(name)
|
|
162
|
-
raise ArgumentError, "job name \"#{name}\" must be 1 to 120 characters of letters, digits, \".\", \"_\", \":\" or \"-\""
|
|
165
|
+
raise ArgumentError, "job name \"#{name}\" must be 1 to 120 characters of letters, digits, \".\", \"_\", \":\", or \"-\""
|
|
163
166
|
end
|
|
164
167
|
|
|
165
168
|
definition = build_definition(name, options)
|
|
166
169
|
validate_definition(definition)
|
|
167
170
|
@registry.synchronize do
|
|
168
171
|
@definitions[name] = definition
|
|
172
|
+
@forgotten.delete(name)
|
|
169
173
|
@synced.delete(name)
|
|
170
174
|
end
|
|
171
175
|
JobHandle.new(self, definition)
|
|
172
176
|
end
|
|
173
177
|
|
|
178
|
+
# The declaration of `name`: the standing one, else one forgotten here,
|
|
179
|
+
# declared again as it was.
|
|
180
|
+
def declared(name)
|
|
181
|
+
@registry.synchronize do
|
|
182
|
+
standing = @definitions[name]
|
|
183
|
+
next standing if standing
|
|
184
|
+
|
|
185
|
+
forgotten = @forgotten.delete(name)
|
|
186
|
+
if forgotten
|
|
187
|
+
@definitions[name] = forgotten
|
|
188
|
+
@synced.delete(name)
|
|
189
|
+
end
|
|
190
|
+
forgotten
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
private :declared
|
|
194
|
+
|
|
174
195
|
# Run a job by name without keeping a handle, declaring it on first use
|
|
175
196
|
# (or again, when options are given).
|
|
176
197
|
#
|
|
@@ -184,8 +205,8 @@ module Cronwatch
|
|
|
184
205
|
return get_run(name_or_id)
|
|
185
206
|
end
|
|
186
207
|
|
|
187
|
-
|
|
188
|
-
handle =
|
|
208
|
+
definition = options.any? ? nil : declared(name_or_id.to_s)
|
|
209
|
+
handle = definition.nil? ? job(name_or_id, **options) : JobHandle.new(self, definition)
|
|
189
210
|
handle.run(&block)
|
|
190
211
|
end
|
|
191
212
|
|
|
@@ -205,8 +226,14 @@ module Cronwatch
|
|
|
205
226
|
# error message, or nil when the result is fine: an HTTP handler uses it to
|
|
206
227
|
# count a 500 response as a failed run.
|
|
207
228
|
#
|
|
229
|
+
# `requeued_on_shutdown` is for a queue that puts a job back when its
|
|
230
|
+
# worker shuts down (Sidekiq raises Sidekiq::Shutdown into the job and
|
|
231
|
+
# runs it again after the restart): a run stopped that way is recorded as
|
|
232
|
+
# failed but not judged, so a deploy opens no failed alert and does not
|
|
233
|
+
# count toward failures_before_alert.
|
|
234
|
+
#
|
|
208
235
|
# @api private For integrations (JobHandle#run, ActiveJob, Sidekiq), not apps.
|
|
209
|
-
def execute(definition, trigger, failure: nil)
|
|
236
|
+
def execute(definition, trigger, failure: nil, requeued_on_shutdown: false)
|
|
210
237
|
after_fork_check
|
|
211
238
|
name = definition.name
|
|
212
239
|
started_at = now
|
|
@@ -224,7 +251,7 @@ module Cronwatch
|
|
|
224
251
|
# just before the block runs. The result is the same.
|
|
225
252
|
if recorded
|
|
226
253
|
begin
|
|
227
|
-
update_state(name) { |before| [Evaluate.on_run_start(before), nil] }
|
|
254
|
+
update_state(name) { |before| [Evaluate.hold_run(Evaluate.on_run_start(before), run.id), nil] }
|
|
228
255
|
rescue StandardError => e
|
|
229
256
|
report(e, "starting #{name}")
|
|
230
257
|
end
|
|
@@ -252,7 +279,8 @@ module Cronwatch
|
|
|
252
279
|
|
|
253
280
|
conclude(definition, run, result, error, threw, recorder.expect_text || returned, failure: failure)
|
|
254
281
|
begin
|
|
255
|
-
|
|
282
|
+
judge = !(requeued_on_shutdown && threw && Client.shutdown?(error))
|
|
283
|
+
ignored = record_finish(definition, run, recorded, finished_at, judge: judge)
|
|
256
284
|
report(RuntimeError.new("run #{run.id} of #{name} #{ignored}; ignored"), "finishing #{name}") if ignored
|
|
257
285
|
rescue StandardError => e
|
|
258
286
|
report(e, "recording #{name}")
|
|
@@ -267,7 +295,7 @@ module Cronwatch
|
|
|
267
295
|
# job must be declared in this process, or it raises ArgumentError.
|
|
268
296
|
def resume_run(name, run_id)
|
|
269
297
|
name = name.to_s if name.is_a?(Symbol)
|
|
270
|
-
definition =
|
|
298
|
+
definition = declared(name)
|
|
271
299
|
raise ArgumentError, "resume_run: job \"#{name}\" is not declared; call job first" unless definition
|
|
272
300
|
|
|
273
301
|
resume_handle(definition, run_id)
|
|
@@ -397,7 +425,7 @@ module Cronwatch
|
|
|
397
425
|
end
|
|
398
426
|
return [] unless evaluate
|
|
399
427
|
|
|
400
|
-
update_state(run.job) { |before| [Evaluate.on_run_start(before), nil] }
|
|
428
|
+
update_state(run.job) { |before| [Evaluate.hold_run(Evaluate.on_run_start(before), run.id), nil] }
|
|
401
429
|
return [] if run.status == :running
|
|
402
430
|
|
|
403
431
|
finish_run(definition, run, now)
|
|
@@ -496,13 +524,15 @@ module Cronwatch
|
|
|
496
524
|
end
|
|
497
525
|
|
|
498
526
|
# Remove a job and its runs from the store. A job still declared in code
|
|
499
|
-
# comes back: here on its next run,
|
|
500
|
-
#
|
|
501
|
-
#
|
|
527
|
+
# comes back, with no history: here on its next run, declared as it was
|
|
528
|
+
# written (run and resume_run by name included), and in any other process
|
|
529
|
+
# that declares it on its next run there, or at that process's next check
|
|
530
|
+
# or dashboard read.
|
|
502
531
|
def forget(name)
|
|
503
532
|
ensure_ready
|
|
504
533
|
@registry.synchronize do
|
|
505
|
-
@definitions.delete(name)
|
|
534
|
+
definition = @definitions.delete(name)
|
|
535
|
+
@forgotten[name] = definition if definition
|
|
506
536
|
@synced.delete(name)
|
|
507
537
|
end
|
|
508
538
|
@store.delete_job(name)
|
|
@@ -618,7 +648,7 @@ module Cronwatch
|
|
|
618
648
|
ticker
|
|
619
649
|
end
|
|
620
650
|
|
|
621
|
-
# Locks, the check in flight, the interval thread and the channel and
|
|
651
|
+
# Locks, the check in flight, the interval thread, and the channel and
|
|
622
652
|
# triage threads belong to one process. A forked child (Puma, Unicorn,
|
|
623
653
|
# Sidekiq) starts with fresh ones, so start and check work there.
|
|
624
654
|
def reset_process_state
|
|
@@ -718,7 +748,7 @@ module Cronwatch
|
|
|
718
748
|
expect = definition.expect
|
|
719
749
|
return if expect.nil? || expect.is_a?(String) || expect.is_a?(Regexp) || expect.respond_to?(:call)
|
|
720
750
|
|
|
721
|
-
raise ArgumentError, "job \"#{name}\": expect must be a string, a RegExp or a function"
|
|
751
|
+
raise ArgumentError, "job \"#{name}\": expect must be a string, a RegExp, or a function"
|
|
722
752
|
end
|
|
723
753
|
|
|
724
754
|
# String(value) as JavaScript writes it, for the messages above.
|
|
@@ -886,11 +916,24 @@ module Cronwatch
|
|
|
886
916
|
report(RuntimeError.new("run #{run.id} of #{run.job} #{ignored}; ignored"), "recording #{run.job}")
|
|
887
917
|
return []
|
|
888
918
|
end
|
|
889
|
-
|
|
919
|
+
unless evaluate
|
|
920
|
+
# Stored unjudged on purpose: a check must not evaluate it as one whose evaluation was lost.
|
|
921
|
+
release_unjudged(run)
|
|
922
|
+
return []
|
|
923
|
+
end
|
|
924
|
+
return [] if late && run.status != :ok
|
|
890
925
|
|
|
891
926
|
finish_run(definition, run, now)
|
|
892
927
|
end
|
|
893
928
|
|
|
929
|
+
# Takes a run stored without judging out of the job's pending runs, so a
|
|
930
|
+
# check does not evaluate it as one whose evaluation was lost.
|
|
931
|
+
def release_unjudged(run)
|
|
932
|
+
update_state(run.job) { |current| [Evaluate.release_run(current, run.id), nil] }
|
|
933
|
+
rescue StandardError => e
|
|
934
|
+
report(e, "recording #{run.job}")
|
|
935
|
+
end
|
|
936
|
+
|
|
894
937
|
# A conditional write (the store's update_run_if), or for a store
|
|
895
938
|
# without one, a read then a plain write, which is safe only while one
|
|
896
939
|
# process at a time finishes a given run.
|
|
@@ -926,7 +969,7 @@ module Cronwatch
|
|
|
926
969
|
if threw
|
|
927
970
|
run.status = :failed
|
|
928
971
|
run.error = Output.describe_error(error)
|
|
929
|
-
elsif (problem = failure
|
|
972
|
+
elsif (problem = failure_of(failure, result))
|
|
930
973
|
run.status = :failed
|
|
931
974
|
run.error = Output.utf8(problem.to_s)
|
|
932
975
|
else
|
|
@@ -945,18 +988,26 @@ module Cronwatch
|
|
|
945
988
|
run.error = Output.redact_and_cap(run.error, @redact) unless run.error.nil?
|
|
946
989
|
end
|
|
947
990
|
|
|
991
|
+
# What the failure: callable makes of the result, or nil. One that raises
|
|
992
|
+
# fails the run with its error described, so the run is still recorded.
|
|
993
|
+
def failure_of(failure, result)
|
|
994
|
+
failure&.call(result)
|
|
995
|
+
rescue StandardError => e
|
|
996
|
+
Output.describe_error(e)
|
|
997
|
+
end
|
|
998
|
+
|
|
948
999
|
# Writes a finished run and evaluates it. `recorded` says whether its
|
|
949
1000
|
# start was written; if not, it is inserted now. Returns why nothing was
|
|
950
1001
|
# recorded (another process finished the run first, say), or nil. Raises
|
|
951
1002
|
# when the store does, so a handle can be finished again. Shared by
|
|
952
1003
|
# execute and RunHandle#finish.
|
|
953
|
-
def record_finish(definition, run, recorded, finished_at)
|
|
1004
|
+
def record_finish(definition, run, recorded, finished_at, judge: true)
|
|
954
1005
|
unless recorded
|
|
955
1006
|
# The start was never written; the store may be back by now.
|
|
956
1007
|
sync(definition)
|
|
957
1008
|
begin
|
|
958
1009
|
@store.insert_run(run)
|
|
959
|
-
finish_run(Serialize.to_stored(definition), run, finished_at)
|
|
1010
|
+
finish_run(Serialize.to_stored(definition), run, finished_at) if judge
|
|
960
1011
|
return nil
|
|
961
1012
|
rescue StandardError => e
|
|
962
1013
|
# Another process may have recorded a run with this id meanwhile.
|
|
@@ -972,10 +1023,22 @@ module Cronwatch
|
|
|
972
1023
|
late, ignored = claim_finish(run)
|
|
973
1024
|
return ignored if ignored
|
|
974
1025
|
|
|
975
|
-
|
|
1026
|
+
if !judge
|
|
1027
|
+
release_unjudged(run)
|
|
1028
|
+
elsif !late || run.status == :ok
|
|
1029
|
+
finish_run(Serialize.to_stored(definition), run, finished_at)
|
|
1030
|
+
end
|
|
976
1031
|
nil
|
|
977
1032
|
end
|
|
978
1033
|
|
|
1034
|
+
# A job's worker shutting down, which its queue answers by running the job
|
|
1035
|
+
# again: Sidekiq::Shutdown, when Sidekiq is loaded.
|
|
1036
|
+
#
|
|
1037
|
+
# @api private
|
|
1038
|
+
def self.shutdown?(error)
|
|
1039
|
+
defined?(::Sidekiq::Shutdown) && error.is_a?(::Sidekiq::Shutdown) ? true : false
|
|
1040
|
+
end
|
|
1041
|
+
|
|
979
1042
|
# The start of execute without the block: the run is inserted and missed
|
|
980
1043
|
# and stuck close (on_run_start). A store that fails is reported and the
|
|
981
1044
|
# handle inserts the finished run instead, as execute does.
|
|
@@ -1011,7 +1074,7 @@ module Cronwatch
|
|
|
1011
1074
|
end
|
|
1012
1075
|
if recorded
|
|
1013
1076
|
begin
|
|
1014
|
-
update_state(name) { |before| [Evaluate.on_run_start(before), nil] }
|
|
1077
|
+
update_state(name) { |before| [Evaluate.hold_run(Evaluate.on_run_start(before), run.id), nil] }
|
|
1015
1078
|
rescue StandardError => e
|
|
1016
1079
|
report(e, "starting #{name}")
|
|
1017
1080
|
end
|
|
@@ -1162,6 +1225,30 @@ module Cronwatch
|
|
|
1162
1225
|
joined && Output.cap(joined)
|
|
1163
1226
|
end
|
|
1164
1227
|
|
|
1228
|
+
# Runs the job's state still holds as pending (Evaluate.hold_run) whose
|
|
1229
|
+
# finish was written but never evaluated: the process that finished them
|
|
1230
|
+
# stopped, or its state write failed, between the two writes. Each one
|
|
1231
|
+
# finished more than SEND_LEASE_MS ago is evaluated now, as its finish
|
|
1232
|
+
# would have been; one still running is left to the stuck check, and one
|
|
1233
|
+
# the store no longer has (given back, or pruned) is let go. The wait
|
|
1234
|
+
# leaves a finish under way in another process to write its own evaluation.
|
|
1235
|
+
def evaluate_pending(stored, recent, state, at)
|
|
1236
|
+
alerts = []
|
|
1237
|
+
(state.pending_runs || []).each do |id|
|
|
1238
|
+
run = recent.find { |r| r.id == id } || @store.get_run(id)
|
|
1239
|
+
if run.nil? || run.job != stored.name
|
|
1240
|
+
update_state(stored.name) { |current| [Evaluate.release_run(current, id), nil] }
|
|
1241
|
+
next
|
|
1242
|
+
end
|
|
1243
|
+
next if run.status == :running || !run.finished_at.is_a?(Numeric) || run.finished_at > at - Evaluate::SEND_LEASE_MS
|
|
1244
|
+
|
|
1245
|
+
alerts.concat(finish_run(stored.definition, run, at))
|
|
1246
|
+
rescue StandardError => e
|
|
1247
|
+
report(e, "evaluating run #{id} of #{stored.name}")
|
|
1248
|
+
end
|
|
1249
|
+
alerts
|
|
1250
|
+
end
|
|
1251
|
+
|
|
1165
1252
|
# Evaluate a finished run (ok, failed, or timed out by a check), already
|
|
1166
1253
|
# written, against the job's state and send what that produces. The
|
|
1167
1254
|
# alerts are written with that state (see outbox). Never raises.
|
|
@@ -1171,7 +1258,8 @@ module Cronwatch
|
|
|
1171
1258
|
past = nil
|
|
1172
1259
|
_, held = update_state(run.job) do |previous|
|
|
1173
1260
|
past ||= history(run)
|
|
1174
|
-
|
|
1261
|
+
before = Evaluate.release_run(previous, run.id)
|
|
1262
|
+
outbox(Evaluate.apply_silence(before, Evaluate.on_run_finish(definition, run, before, past, at), at), definition, at)
|
|
1175
1263
|
end
|
|
1176
1264
|
rescue StandardError => e
|
|
1177
1265
|
report(e, "evaluating #{run.job}")
|
|
@@ -1280,6 +1368,7 @@ module Cronwatch
|
|
|
1280
1368
|
report_dropped(stored.name, held.dropped)
|
|
1281
1369
|
alerts.concat(retry_undelivered(stored.name, state, at, retries))
|
|
1282
1370
|
alerts.concat(dispatch(stored.name, held.alerts, at))
|
|
1371
|
+
alerts.concat(evaluate_pending(stored, recent, state, at))
|
|
1283
1372
|
jobs << Evaluate.summarize(stored, recent, state, next_expected_at, at)
|
|
1284
1373
|
rescue StandardError => e
|
|
1285
1374
|
report(e, "checking #{stored.name}")
|
|
@@ -1338,7 +1427,7 @@ module Cronwatch
|
|
|
1338
1427
|
Evaluate.unevaluable_summary(stored, recent, state, at)
|
|
1339
1428
|
end
|
|
1340
1429
|
|
|
1341
|
-
# Read, change and write one job's state, in turn with every other update to it.
|
|
1430
|
+
# Read, change, and write one job's state, in turn with every other update to it.
|
|
1342
1431
|
def patch_state(name)
|
|
1343
1432
|
ensure_ready
|
|
1344
1433
|
state, = update_state(name) do |current|
|
|
@@ -1376,22 +1465,36 @@ module Cronwatch
|
|
|
1376
1465
|
RetryBudget = Struct.new(:spent_ms)
|
|
1377
1466
|
|
|
1378
1467
|
# Send the alerts that no channel accepted last time, once each, oldest
|
|
1379
|
-
# first.
|
|
1380
|
-
#
|
|
1381
|
-
#
|
|
1382
|
-
#
|
|
1468
|
+
# first. They are claimed first (Evaluate.claim_undelivered): moved into
|
|
1469
|
+
# `sending` with a lease, in one conditional write, so when several
|
|
1470
|
+
# processes check at once only the one whose write lands sends them. An
|
|
1471
|
+
# alert that no longer describes the job (Evaluate.stale_alert?) is
|
|
1472
|
+
# dropped instead. Retries across a check share RETRY_BUDGET_MS of
|
|
1473
|
+
# wall-clock time; claimed alerts it has no time left for go back to the
|
|
1474
|
+
# queue for the next check.
|
|
1383
1475
|
def retry_undelivered(name, state, at, budget)
|
|
1384
|
-
|
|
1385
|
-
|
|
1476
|
+
return [] if (state.undelivered || []).empty? || @defer_delivery
|
|
1477
|
+
|
|
1478
|
+
begin
|
|
1479
|
+
_, claimed = update_state(name) do |previous|
|
|
1480
|
+
claim = Evaluate.claim_undelivered(previous, at, now + Evaluate::SEND_LEASE_MS)
|
|
1481
|
+
[claim.state, claim.claimed]
|
|
1482
|
+
end
|
|
1483
|
+
rescue StandardError => e
|
|
1484
|
+
report(e, "claiming undelivered alerts for #{name}")
|
|
1485
|
+
return []
|
|
1486
|
+
end
|
|
1487
|
+
return [] if claimed.empty?
|
|
1386
1488
|
|
|
1387
1489
|
delivered = []
|
|
1388
1490
|
failed = []
|
|
1389
|
-
|
|
1390
|
-
pending.each do |alert|
|
|
1391
|
-
next if dropped.any? { |d| d.equal?(alert) }
|
|
1392
|
-
|
|
1491
|
+
claimed.each do |alert|
|
|
1393
1492
|
left = @retry_budget_ms - budget.spent_ms
|
|
1394
|
-
|
|
1493
|
+
if left <= 0
|
|
1494
|
+
# Not tried: back to the queue as it was.
|
|
1495
|
+
failed << alert
|
|
1496
|
+
next
|
|
1497
|
+
end
|
|
1395
1498
|
|
|
1396
1499
|
started = AbortSignal.monotonic
|
|
1397
1500
|
# An alert queued by a process that delivers at check time was never
|
|
@@ -1400,7 +1503,7 @@ module Cronwatch
|
|
|
1400
1503
|
(deliver(alert) ? delivered : failed) << alert
|
|
1401
1504
|
budget.spent_ms += [0, ((AbortSignal.monotonic - started) * 1000).round].max
|
|
1402
1505
|
end
|
|
1403
|
-
record_delivery(name, delivered, failed,
|
|
1506
|
+
record_delivery(name, delivered, failed, [], at)
|
|
1404
1507
|
delivered
|
|
1405
1508
|
end
|
|
1406
1509
|
|
|
@@ -1492,13 +1595,17 @@ module Cronwatch
|
|
|
1492
1595
|
end
|
|
1493
1596
|
|
|
1494
1597
|
# Sets the alert's triage to the diagnosis, or to nil (JSON null) when
|
|
1495
|
-
# there is none (it raised, timed out or answered nil or ""), so it is
|
|
1496
|
-
# tried once per alert. While a triage that timed out
|
|
1497
|
-
# alerts go out without one rather than start another
|
|
1598
|
+
# there is none (it raised, timed out, or answered nil or ""), so it is
|
|
1599
|
+
# tried once per alert. While a triage that timed out on its full timeout
|
|
1600
|
+
# is still going, alerts go out without one rather than start another
|
|
1601
|
+
# beside it, and stay untried so a later retry can triage them. A timeout
|
|
1602
|
+
# cut short by the retry budget says nothing about Claude, so it does not
|
|
1603
|
+
# hold back the triages after it.
|
|
1498
1604
|
def add_triage(alert, timeout_ms)
|
|
1499
1605
|
signal = AbortSignal.new
|
|
1500
1606
|
if @sending_lock.synchronize { @abandoned[:triage]&.alive? }
|
|
1501
|
-
|
|
1607
|
+
report(TimeoutError.new("skipped: an earlier triage timed out and is still running"), "triage for #{alert.job}")
|
|
1608
|
+
return
|
|
1502
1609
|
end
|
|
1503
1610
|
|
|
1504
1611
|
recent = @store.list_runs(alert.job, 5)
|
|
@@ -1512,7 +1619,7 @@ module Cronwatch
|
|
|
1512
1619
|
outcome = [:error, e]
|
|
1513
1620
|
end
|
|
1514
1621
|
unless thread.join(timeout_ms / 1000.0)
|
|
1515
|
-
@sending_lock.synchronize { @abandoned[:triage] = thread }
|
|
1622
|
+
@sending_lock.synchronize { @abandoned[:triage] = thread } if timeout_ms >= @triage_timeout_ms
|
|
1516
1623
|
raise TimeoutError, "timed out after #{timeout_ms}ms"
|
|
1517
1624
|
end
|
|
1518
1625
|
outcome ||= [:error, RuntimeError.new("the triage thread ended without an answer")]
|
data/lib/cronwatch/duration.rb
CHANGED
|
@@ -34,7 +34,7 @@ module Cronwatch
|
|
|
34
34
|
return value
|
|
35
35
|
end
|
|
36
36
|
unless value.is_a?(String)
|
|
37
|
-
raise ArgumentError, "#{label} \"#{value}\" is not a duration like \"15m\", \"1h30m\" or \"90s\""
|
|
37
|
+
raise ArgumentError, "#{label} \"#{value}\" is not a duration like \"15m\", \"1h30m\", or \"90s\""
|
|
38
38
|
end
|
|
39
39
|
|
|
40
40
|
if value.length > MAX_LENGTH
|
|
@@ -52,7 +52,7 @@ module Cronwatch
|
|
|
52
52
|
consumed << match[0]
|
|
53
53
|
end
|
|
54
54
|
if consumed.gsub(JS::SPACES, "") != text.gsub(JS::SPACES, "")
|
|
55
|
-
raise ArgumentError, "#{label} \"#{value}\" is not a duration like \"15m\", \"1h30m\" or \"90s\""
|
|
55
|
+
raise ArgumentError, "#{label} \"#{value}\" is not a duration like \"15m\", \"1h30m\", or \"90s\""
|
|
56
56
|
end
|
|
57
57
|
|
|
58
58
|
JS.round(total)
|
|
@@ -4,19 +4,17 @@ module Cronwatch
|
|
|
4
4
|
# The app's environment, read as every CronWatch library reads it: the
|
|
5
5
|
# first of CRONWATCH_ENV, APP_ENV, then the app's own (Rails.env when Rails
|
|
6
6
|
# is loaded, else RAILS_ENV, then RACK_ENV) that holds more than spaces,
|
|
7
|
-
# trimmed and lowercased, with "prod" read as "production" and "dev"
|
|
8
|
-
# "local"
|
|
9
|
-
#
|
|
10
|
-
#
|
|
7
|
+
# trimmed and lowercased, with "prod" read as "production" and "dev" and
|
|
8
|
+
# "local" as "development". "test" and "testing" are not development: a
|
|
9
|
+
# test run or CI needs CRON_SECRET or a token, as production does. None set
|
|
10
|
+
# is neither, which is the safe reading. The SDK reads NODE_ENV where this
|
|
11
|
+
# reads the app's own.
|
|
11
12
|
#
|
|
12
13
|
# @api private
|
|
13
14
|
module Environment
|
|
14
15
|
# The variables read before the app's own, in order.
|
|
15
16
|
SHARED = %w[CRONWATCH_ENV APP_ENV].freeze
|
|
16
|
-
ALIASES = {
|
|
17
|
-
"prod" => "production", "dev" => "development", "local" => "development", "test" => "development",
|
|
18
|
-
"testing" => "development",
|
|
19
|
-
}.freeze
|
|
17
|
+
ALIASES = { "prod" => "production", "dev" => "development", "local" => "development" }.freeze
|
|
20
18
|
private_constant :SHARED, :ALIASES
|
|
21
19
|
|
|
22
20
|
module_function
|
|
@@ -59,20 +57,21 @@ module Cronwatch
|
|
|
59
57
|
JS.trim(value.dup.force_encoding(Encoding::UTF_8).scrub).empty?
|
|
60
58
|
end
|
|
61
59
|
|
|
62
|
-
# A secret from the environment (CRONWATCH_TOKEN, CRON_SECRET)
|
|
63
|
-
#
|
|
64
|
-
#
|
|
65
|
-
#
|
|
60
|
+
# A secret from the environment (CRONWATCH_TOKEN, CRON_SECRET), trimmed as
|
|
61
|
+
# JavaScript's String.prototype.trim trims (JS::WHITESPACE): a value read
|
|
62
|
+
# from a file often ends in a newline that no header can carry, since
|
|
63
|
+
# HTTP strips the whitespace around a header's value. nil when the
|
|
64
|
+
# variable is unset, empty, or only whitespace, so a blank value counts as
|
|
65
|
+
# not set and the routes fail closed.
|
|
66
66
|
def secret(variable)
|
|
67
|
-
|
|
68
|
-
present?(value) ? value : nil
|
|
67
|
+
trimmed(ENV.fetch(variable, nil))
|
|
69
68
|
end
|
|
70
69
|
|
|
71
|
-
# A token or secret passed in code: a String, or nil. A String
|
|
72
|
-
#
|
|
73
|
-
#
|
|
74
|
-
#
|
|
75
|
-
# password.
|
|
70
|
+
# A token or secret passed in code: a String, or nil. A String is
|
|
71
|
+
# trimmed, as secret trims, and one that is empty or only whitespace
|
|
72
|
+
# counts as not given (nil here; the caller tells that from nil the
|
|
73
|
+
# opt-out). Anything else (false, a number, a Symbol) raises TypeError
|
|
74
|
+
# naming the option, so it never becomes a password.
|
|
76
75
|
def secret_option(value, what)
|
|
77
76
|
return nil if value.nil?
|
|
78
77
|
unless value.is_a?(String)
|
|
@@ -80,7 +79,19 @@ module Cronwatch
|
|
|
80
79
|
raise TypeError, "#{what} must be a String, or nil to opt out, not #{kind}"
|
|
81
80
|
end
|
|
82
81
|
|
|
83
|
-
|
|
82
|
+
trimmed(value)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# A secret trimmed as JavaScript trims, or nil when nothing is left. Bytes
|
|
86
|
+
# that are not UTF-8 are kept as they are.
|
|
87
|
+
def trimmed(value)
|
|
88
|
+
return nil unless present?(value)
|
|
89
|
+
|
|
90
|
+
begin
|
|
91
|
+
JS.trim(value)
|
|
92
|
+
rescue ArgumentError
|
|
93
|
+
value
|
|
94
|
+
end
|
|
84
95
|
end
|
|
85
96
|
|
|
86
97
|
# The framework's own name for the environment, as it sets it: Rails.env
|
|
@@ -94,7 +105,7 @@ module Cronwatch
|
|
|
94
105
|
[ENV.fetch("RAILS_ENV", nil), ENV.fetch("RACK_ENV", nil)].find { |value| value && !value.empty? }
|
|
95
106
|
end
|
|
96
107
|
|
|
97
|
-
# Development (
|
|
108
|
+
# Development (not test): Cronwatch::Web without a token serves only then.
|
|
98
109
|
def development?
|
|
99
110
|
environment == "development"
|
|
100
111
|
end
|
|
@@ -104,10 +115,10 @@ module Cronwatch
|
|
|
104
115
|
end
|
|
105
116
|
|
|
106
117
|
# Whether development was named by the app rather than by its server,
|
|
107
|
-
# for the dashboard's own token (Cronwatch::Web). Puma, Unicorn, Thin
|
|
118
|
+
# for the dashboard's own token (Cronwatch::Web). Puma, Unicorn, Thin,
|
|
108
119
|
# and rackup set RACK_ENV to "development" when nothing names an
|
|
109
120
|
# environment, so under one of them, in production too, RACK_ENV alone
|
|
110
|
-
# reads "development". There it takes CRONWATCH_ENV, APP_ENV, Rails.env
|
|
121
|
+
# reads "development". There it takes CRONWATCH_ENV, APP_ENV, Rails.env,
|
|
111
122
|
# or RAILS_ENV to say development; any other value of RACK_ENV that reads
|
|
112
123
|
# as development ("test", "dev") still counts, as no server sets it.
|
|
113
124
|
def stated_development?
|