cronwatch 0.13.1 → 0.14.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 +1 -1
- data/lib/cronwatch/alerts/email.rb +1 -1
- data/lib/cronwatch/alerts/slack.rb +10 -2
- data/lib/cronwatch/alerts/webhook.rb +35 -9
- data/lib/cronwatch/client.rb +76 -38
- data/lib/cronwatch/environment.rb +1 -1
- data/lib/cronwatch/evaluate.rb +174 -31
- data/lib/cronwatch/http.rb +10 -9
- data/lib/cronwatch/job.rb +20 -8
- data/lib/cronwatch/schedule.rb +166 -51
- data/lib/cronwatch/triage/anthropic.rb +3 -3
- data/lib/cronwatch/types.rb +21 -0
- data/lib/cronwatch/version.rb +1 -1
- data/lib/cronwatch/web/app.rb +50 -6
- data/lib/cronwatch/web/timeline.rb +14 -10
- data/lib/cronwatch/zone.rb +3 -0
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d0a9fcfe8eb1b18e8afd7b80e2b41e62569bf606db798252d2305f9f353235ff
|
|
4
|
+
data.tar.gz: 12626c9fc256348658a3843ac1704b97ef7cd4fde62f289e0c570c174dd29704
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4d9274543edac3d3400762e5863b08a2b9cf8a818b134996e28a292cfe802552e6ecffbc68b18a7a4a9258499e476e7e7982a546b4b204ebc5f146829e14cebc
|
|
7
|
+
data.tar.gz: ab1c1ef624f8e94f56eb73c2be364ebf9b799e1b7bceef3a69e4398604e72c1eb63252f894aec82bdb4dfc61d9b9803048a02564dd60d807a273cfde62b0b364
|
data/README.md
CHANGED
|
@@ -214,7 +214,7 @@ Cronwatch::Alerts::Custom.new("pagerduty") do |alert|
|
|
|
214
214
|
end
|
|
215
215
|
```
|
|
216
216
|
|
|
217
|
-
Every alert goes to every channel. A channel that raises, or takes longer than 15 seconds, goes to `on_error` and never blocks the others. Each condition alerts once when it opens and once more when a clean run closes it; there are no repeat alerts. The webhook's body is the alert's JSON with `"schema": 1` first (its [JSON Schema](https://cronwatch.dev/schemas/webhook/1.json)); parse the fields, not `title` and `message`, whose wording may change. It signs the body with `X-CronWatch-Signature: sha256=<hex>`, the HMAC-SHA256 of the raw body,
|
|
217
|
+
Every alert goes to every channel. A channel that raises, or takes longer than 15 seconds, goes to `on_error` and never blocks the others, and the alert is kept for that channel alone: each check retries it there, and only there, until it takes it. Each condition alerts once when it opens and once more when a clean run closes it; there are no repeat alerts. The webhook's body is the alert's JSON with `"schema": 1` first (its [JSON Schema](https://cronwatch.dev/schemas/webhook/1.json)); parse the fields, not `title` and `message`, whose wording may change. It signs the body with `X-CronWatch-Signature: sha256=<hex>`, the HMAC-SHA256 of the raw body, and sends `X-CronWatch-Timestamp` (unix seconds) with `X-CronWatch-Timestamp-Signature: sha256=<hex>`, the HMAC-SHA256 of `<timestamp>.<raw body>`. A receiver checks the second with `Rack::Utils.secure_compare("sha256=#{Cronwatch::Alerts::Webhook.signature(secret, "#{timestamp}.#{raw_body}")}", header)` and refuses a timestamp more than five minutes from its own clock, so a captured alert cannot be sent again later.
|
|
218
218
|
|
|
219
219
|
## Triage
|
|
220
220
|
|
|
@@ -10,7 +10,7 @@ module Cronwatch
|
|
|
10
10
|
# from: the sender, "alerts@example.com" or "CronWatch <alerts@example.com>"
|
|
11
11
|
# to: one address or several
|
|
12
12
|
# subject_prefix: put in front of the title in the subject, "[prod]" say
|
|
13
|
-
# link: ->(alert) { "https://app.example.com/cronwatch/jobs/#{alert.job}" }
|
|
13
|
+
# link: ->(alert) { "https://app.example.com/cronwatch/jobs/#{ERB::Util.url_encode(alert.job)}" }
|
|
14
14
|
#
|
|
15
15
|
# @api private
|
|
16
16
|
module Email
|
|
@@ -5,7 +5,9 @@ module Cronwatch
|
|
|
5
5
|
# Sends alerts to a Slack channel through an incoming webhook.
|
|
6
6
|
#
|
|
7
7
|
# Cronwatch::Alerts::Slack.new(webhook_url: ENV.fetch("SLACK_WEBHOOK_URL"),
|
|
8
|
-
# link: ->(alert) { "https://app.example.com/cronwatch/jobs/#{alert.job}" })
|
|
8
|
+
# link: ->(alert) { "https://app.example.com/cronwatch/jobs/#{ERB::Util.url_encode(alert.job)}" })
|
|
9
|
+
#
|
|
10
|
+
# Any `<`, `>`, or `|` in the link is percent-encoded, so it cannot end Slack's link early.
|
|
9
11
|
class Slack
|
|
10
12
|
EMOJI = {
|
|
11
13
|
missed: ":hourglass_flowing_sand:", failed: ":x:", stuck: ":no_entry:", slow: ":turtle:",
|
|
@@ -26,7 +28,7 @@ module Cronwatch
|
|
|
26
28
|
|
|
27
29
|
def call(alert)
|
|
28
30
|
url = @link&.call(alert)
|
|
29
|
-
title = "#{EMOJI[alert.type]} *#{Slack.escape(alert.title)}*#{url && !url.empty? ? " (<#{url}|open>)" : ""}"
|
|
31
|
+
title = "#{EMOJI[alert.type]} *#{Slack.escape(alert.title)}*#{url && !url.empty? ? " (<#{Slack.link_target(url)}|open>)" : ""}"
|
|
30
32
|
body = JS.head16(Slack.code_block_safe(Slack.escape(alert.message)), 2900)
|
|
31
33
|
blocks = [
|
|
32
34
|
{ "type" => "section", "text" => { "type" => "mrkdwn", "text" => title } },
|
|
@@ -50,6 +52,12 @@ module Cronwatch
|
|
|
50
52
|
text.gsub("&", "&").gsub("<", "<").gsub(">", ">")
|
|
51
53
|
end
|
|
52
54
|
|
|
55
|
+
# A link's URL as Slack's <url|text> can hold it: `<`, `>`, and `|`
|
|
56
|
+
# percent-encoded, which ends neither the link nor its text.
|
|
57
|
+
def self.link_target(url)
|
|
58
|
+
url.gsub("<", "%3C").gsub(">", "%3E").gsub("|", "%7C")
|
|
59
|
+
end
|
|
60
|
+
|
|
53
61
|
# Breaks up ``` so text inside a code block cannot close it.
|
|
54
62
|
def self.code_block_safe(text)
|
|
55
63
|
text.gsub("```", "`\u200b`\u200b`")
|
|
@@ -7,21 +7,35 @@ module Cronwatch
|
|
|
7
7
|
# POSTs the alert as JSON to any URL. The body is `"schema": 1` and then
|
|
8
8
|
# the alert's JSON: { schema, type, run, details, job, definition, title,
|
|
9
9
|
# message, at, triage }, described by https://cronwatch.dev/schemas/webhook/1.json.
|
|
10
|
-
# With a secret, each request
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
10
|
+
# With a secret, each request is signed with it (HMAC-SHA256, as
|
|
11
|
+
# lowercase hex; Webhook.signature), so the receiver can verify it:
|
|
12
|
+
#
|
|
13
|
+
# - `X-CronWatch-Signature: sha256=<hex>` signs the raw body alone, as it
|
|
14
|
+
# always has.
|
|
15
|
+
# - `X-CronWatch-Timestamp: <unix seconds>` is when it was sent, and
|
|
16
|
+
# `X-CronWatch-Timestamp-Signature: sha256=<hex>` signs
|
|
17
|
+
# `<timestamp>.<raw body>`, so a receiver that checks this one (in
|
|
18
|
+
# constant time) and refuses a timestamp more than five minutes from its
|
|
19
|
+
# own clock cannot be sent a captured alert again later.
|
|
20
|
+
#
|
|
21
|
+
# Header values are trimmed, and a name is matched without regard to
|
|
22
|
+
# case, so a Content-Type given in `headers` replaces the default one
|
|
23
|
+
# rather than going beside it; the signature headers are always the
|
|
24
|
+
# channel's own. A redirect is an error, not followed (the headers and the
|
|
25
|
+
# signature would go with it): point the url at where the receiver really
|
|
26
|
+
# is. `now` is the clock the timestamp is read from, a callable returning
|
|
27
|
+
# epoch milliseconds. For tests.
|
|
15
28
|
class Webhook
|
|
16
29
|
attr_reader :name
|
|
17
30
|
|
|
18
|
-
def initialize(url:, headers: {}, secret: nil, http: HTTP.default)
|
|
31
|
+
def initialize(url:, headers: {}, secret: nil, now: nil, http: HTTP.default)
|
|
19
32
|
raise ArgumentError, "Cronwatch::Alerts::Webhook needs a url" if url.nil? || url.to_s.empty?
|
|
20
33
|
|
|
21
34
|
@name = "webhook"
|
|
22
35
|
@url = url
|
|
23
36
|
@headers = headers || {}
|
|
24
37
|
@secret = secret
|
|
38
|
+
@now = now || -> { Process.clock_gettime(Process::CLOCK_REALTIME, :millisecond) }
|
|
25
39
|
@http = http
|
|
26
40
|
end
|
|
27
41
|
|
|
@@ -32,7 +46,9 @@ module Cronwatch
|
|
|
32
46
|
|
|
33
47
|
# The HMAC-SHA256 of `body` with `secret`, as lowercase hex, without the
|
|
34
48
|
# `sha256=` the header puts before it. A receiver compares it with the
|
|
35
|
-
# header in constant time (Rack::Utils.secure_compare).
|
|
49
|
+
# header in constant time (Rack::Utils.secure_compare). The timestamped
|
|
50
|
+
# one is the signature of `<timestamp>.<body>`, with the
|
|
51
|
+
# X-CronWatch-Timestamp value as sent.
|
|
36
52
|
def self.signature(secret, body)
|
|
37
53
|
OpenSSL::HMAC.hexdigest("SHA256", secret.to_s, body.to_s)
|
|
38
54
|
end
|
|
@@ -48,15 +64,25 @@ module Cronwatch
|
|
|
48
64
|
end
|
|
49
65
|
headers = { "content-type" => "application/json", "user-agent" => "cronwatch" }
|
|
50
66
|
# A pasted Authorization value often carries a stray space or newline, which a header would refuse.
|
|
51
|
-
@headers.each { |name, value| headers
|
|
67
|
+
@headers.each { |name, value| Webhook.set_header(headers, name.to_s, value.is_a?(String) ? JS.trim(value) : value) }
|
|
52
68
|
if @secret && !@secret.empty?
|
|
53
|
-
|
|
69
|
+
timestamp = @now.call.div(1000).to_s
|
|
70
|
+
Webhook.set_header(headers, "x-cronwatch-signature", "sha256=#{Webhook.signature(@secret, body)}")
|
|
71
|
+
Webhook.set_header(headers, "x-cronwatch-timestamp", timestamp)
|
|
72
|
+
Webhook.set_header(headers, "x-cronwatch-timestamp-signature", "sha256=#{Webhook.signature(@secret, "#{timestamp}.#{body}")}")
|
|
54
73
|
end
|
|
55
74
|
response = @http.post(@url, body, headers)
|
|
56
75
|
# Only the origin: a webhook URL's path or query often is the credential.
|
|
57
76
|
raise "Webhook #{Webhook.origin(@url)} answered #{response.status}" unless response.ok?
|
|
58
77
|
end
|
|
59
78
|
|
|
79
|
+
# Sets a header, first taking out any of the same name in another case.
|
|
80
|
+
def self.set_header(headers, name, value)
|
|
81
|
+
lower = name.downcase
|
|
82
|
+
headers.delete_if { |key, _| key.downcase == lower }
|
|
83
|
+
headers[name] = value
|
|
84
|
+
end
|
|
85
|
+
|
|
60
86
|
def self.origin(url)
|
|
61
87
|
uri = URI(HTTP.clean_url(url))
|
|
62
88
|
raise URI::InvalidURIError unless uri.scheme && uri.host
|
data/lib/cronwatch/client.rb
CHANGED
|
@@ -251,7 +251,7 @@ module Cronwatch
|
|
|
251
251
|
# just before the block runs. The result is the same.
|
|
252
252
|
if recorded
|
|
253
253
|
begin
|
|
254
|
-
update_state(name) { |before| [Evaluate.hold_run(Evaluate.on_run_start(before), run.id), nil] }
|
|
254
|
+
update_state(name) { |before| [Evaluate.hold_run(Evaluate.on_run_start(definition, run, before, now), run.id), nil] }
|
|
255
255
|
rescue StandardError => e
|
|
256
256
|
report(e, "starting #{name}")
|
|
257
257
|
end
|
|
@@ -425,10 +425,10 @@ module Cronwatch
|
|
|
425
425
|
end
|
|
426
426
|
return [] unless evaluate
|
|
427
427
|
|
|
428
|
-
update_state(run.job) { |before| [Evaluate.hold_run(Evaluate.on_run_start(before), run.id), nil] }
|
|
428
|
+
update_state(run.job) { |before| [Evaluate.hold_run(Evaluate.on_run_start(definition, run, before, now), run.id), nil] }
|
|
429
429
|
return [] if run.status == :running
|
|
430
430
|
|
|
431
|
-
finish_run(definition, run, now)
|
|
431
|
+
finish_run(definition, run, now, pending_only: true)
|
|
432
432
|
end
|
|
433
433
|
|
|
434
434
|
# Hands an error to on_error, as a source reports what went wrong. See #report.
|
|
@@ -437,9 +437,9 @@ module Cronwatch
|
|
|
437
437
|
end
|
|
438
438
|
|
|
439
439
|
# Look for missed and stuck runs across every job, send alerts, retry
|
|
440
|
-
# alerts
|
|
441
|
-
# scheduled job (Cronwatch::CheckJob), or by hand.
|
|
442
|
-
# one check.
|
|
440
|
+
# alerts to the channels that refused them, and prune old runs. Call it
|
|
441
|
+
# from start_checking, a scheduled job (Cronwatch::CheckJob), or by hand.
|
|
442
|
+
# Concurrent calls share one check.
|
|
443
443
|
def check
|
|
444
444
|
after_fork_check
|
|
445
445
|
flight = nil
|
|
@@ -763,10 +763,12 @@ module Cronwatch
|
|
|
763
763
|
def ensure_ready
|
|
764
764
|
return if @ready
|
|
765
765
|
|
|
766
|
+
missing = nil
|
|
766
767
|
@ready_lock.synchronize do
|
|
767
768
|
next if @ready
|
|
768
769
|
|
|
769
770
|
@store.init if @store.respond_to?(:init)
|
|
771
|
+
missing = %i[compare_and_set_state update_run_if].reject { |method| @store.respond_to?(method) }
|
|
770
772
|
if @using_default_store && Environment.production?
|
|
771
773
|
warn "[cronwatch] using the in-memory store: runs and state are lost on restart. " \
|
|
772
774
|
"Pass a store such as Cronwatch::Stores::ActiveRecord."
|
|
@@ -774,6 +776,12 @@ module Cronwatch
|
|
|
774
776
|
# Only once init has gone through: a failure is tried again on the next call.
|
|
775
777
|
@ready = true
|
|
776
778
|
end
|
|
779
|
+
return if missing.nil? || missing.empty?
|
|
780
|
+
|
|
781
|
+
# Once per client, outside the lock: it still works, but only as long
|
|
782
|
+
# as one process at a time uses the store.
|
|
783
|
+
report(RuntimeError.new("the store has no #{missing.join(" or ")}, so two processes sharing it can overwrite each other's " \
|
|
784
|
+
"state or both evaluate the same run; it is safe only while one process at a time uses it"), "store")
|
|
777
785
|
end
|
|
778
786
|
|
|
779
787
|
# Writes the declaration of `definition`'s name as it stands, unless the
|
|
@@ -911,6 +919,7 @@ module Cronwatch
|
|
|
911
919
|
end
|
|
912
920
|
return [] if !%i[running timeout].include?(stored.status) || run.status == :running
|
|
913
921
|
|
|
922
|
+
tracked = (read_state(run.job).pending_runs || []).include?(run.id)
|
|
914
923
|
late, ignored = claim_finish(run)
|
|
915
924
|
if ignored
|
|
916
925
|
report(RuntimeError.new("run #{run.id} of #{run.job} #{ignored}; ignored"), "recording #{run.job}")
|
|
@@ -923,7 +932,7 @@ module Cronwatch
|
|
|
923
932
|
end
|
|
924
933
|
return [] if late && run.status != :ok
|
|
925
934
|
|
|
926
|
-
finish_run(definition, run, now)
|
|
935
|
+
finish_run(definition, run, now, pending_only: tracked && !late)
|
|
927
936
|
end
|
|
928
937
|
|
|
929
938
|
# Takes a run stored without judging out of the job's pending runs, so a
|
|
@@ -1020,13 +1029,14 @@ module Cronwatch
|
|
|
1020
1029
|
return "belongs to job \"#{stored.job}\"" if stored.job != run.job
|
|
1021
1030
|
end
|
|
1022
1031
|
end
|
|
1032
|
+
tracked = (read_state(run.job).pending_runs || []).include?(run.id)
|
|
1023
1033
|
late, ignored = claim_finish(run)
|
|
1024
1034
|
return ignored if ignored
|
|
1025
1035
|
|
|
1026
1036
|
if !judge
|
|
1027
1037
|
release_unjudged(run)
|
|
1028
1038
|
elsif !late || run.status == :ok
|
|
1029
|
-
finish_run(Serialize.to_stored(definition), run, finished_at)
|
|
1039
|
+
finish_run(Serialize.to_stored(definition), run, finished_at, pending_only: tracked && !late)
|
|
1030
1040
|
end
|
|
1031
1041
|
nil
|
|
1032
1042
|
end
|
|
@@ -1074,7 +1084,7 @@ module Cronwatch
|
|
|
1074
1084
|
end
|
|
1075
1085
|
if recorded
|
|
1076
1086
|
begin
|
|
1077
|
-
update_state(name) { |before| [Evaluate.hold_run(Evaluate.on_run_start(before), run.id), nil] }
|
|
1087
|
+
update_state(name) { |before| [Evaluate.hold_run(Evaluate.on_run_start(definition, run, before, now), run.id), nil] }
|
|
1078
1088
|
rescue StandardError => e
|
|
1079
1089
|
report(e, "starting #{name}")
|
|
1080
1090
|
end
|
|
@@ -1242,7 +1252,7 @@ module Cronwatch
|
|
|
1242
1252
|
end
|
|
1243
1253
|
next if run.status == :running || !run.finished_at.is_a?(Numeric) || run.finished_at > at - Evaluate::SEND_LEASE_MS
|
|
1244
1254
|
|
|
1245
|
-
alerts.concat(finish_run(stored.definition, run, at))
|
|
1255
|
+
alerts.concat(finish_run(stored.definition, run, at, pending_only: true))
|
|
1246
1256
|
rescue StandardError => e
|
|
1247
1257
|
report(e, "evaluating run #{id} of #{stored.name}")
|
|
1248
1258
|
end
|
|
@@ -1252,11 +1262,12 @@ module Cronwatch
|
|
|
1252
1262
|
# Evaluate a finished run (ok, failed, or timed out by a check), already
|
|
1253
1263
|
# written, against the job's state and send what that produces. The
|
|
1254
1264
|
# alerts are written with that state (see outbox). Never raises.
|
|
1255
|
-
def finish_run(definition, run, at)
|
|
1265
|
+
def finish_run(definition, run, at, pending_only: false)
|
|
1256
1266
|
held = nil
|
|
1257
1267
|
begin
|
|
1258
1268
|
past = nil
|
|
1259
1269
|
_, held = update_state(run.job) do |previous|
|
|
1270
|
+
next [previous, Outbox.new([], 0)] if pending_only && !(previous.pending_runs || []).include?(run.id)
|
|
1260
1271
|
past ||= history(run)
|
|
1261
1272
|
before = Evaluate.release_run(previous, run.id)
|
|
1262
1273
|
outbox(Evaluate.apply_silence(before, Evaluate.on_run_finish(definition, run, before, past, at), at), definition, at)
|
|
@@ -1451,11 +1462,15 @@ module Cronwatch
|
|
|
1451
1462
|
|
|
1452
1463
|
delivered = []
|
|
1453
1464
|
failed = []
|
|
1465
|
+
reached = false
|
|
1454
1466
|
alerts.each do |alert|
|
|
1455
1467
|
add_triage(alert, @triage_timeout_ms) if @triage && alert.type != :recovered
|
|
1456
|
-
|
|
1468
|
+
# The alert as composed, full run and all; only a retry reads the trimmed copy held in state.
|
|
1469
|
+
left, took = deliver(alert)
|
|
1470
|
+
reached ||= took
|
|
1471
|
+
left ? failed << left : delivered << alert
|
|
1457
1472
|
end
|
|
1458
|
-
record_delivery(name, delivered, failed, [], at)
|
|
1473
|
+
record_delivery(name, delivered, failed, [], at, reached)
|
|
1459
1474
|
alerts
|
|
1460
1475
|
end
|
|
1461
1476
|
|
|
@@ -1464,14 +1479,16 @@ module Cronwatch
|
|
|
1464
1479
|
# @api private
|
|
1465
1480
|
RetryBudget = Struct.new(:spent_ms)
|
|
1466
1481
|
|
|
1467
|
-
# Send the alerts
|
|
1468
|
-
# first
|
|
1469
|
-
#
|
|
1470
|
-
#
|
|
1471
|
-
#
|
|
1472
|
-
#
|
|
1473
|
-
#
|
|
1474
|
-
#
|
|
1482
|
+
# Send the alerts some channel refused last time, once each, oldest
|
|
1483
|
+
# first, to the channels that still owe them (Alert#owed) and no others,
|
|
1484
|
+
# from the copy held in state, its run trimmed (Evaluate.held_run). They
|
|
1485
|
+
# are claimed first (Evaluate.claim_undelivered): moved into `sending`
|
|
1486
|
+
# with a lease, in one conditional write, so when several processes check
|
|
1487
|
+
# at once only the one whose write lands sends them. An alert that no
|
|
1488
|
+
# longer describes the job (Evaluate.stale_alert?) is dropped instead.
|
|
1489
|
+
# Retries across a check share RETRY_BUDGET_MS of wall-clock time;
|
|
1490
|
+
# claimed alerts it has no time left for go back to the queue for the
|
|
1491
|
+
# next check. Returns the alerts some channel took on this attempt.
|
|
1475
1492
|
def retry_undelivered(name, state, at, budget)
|
|
1476
1493
|
return [] if (state.undelivered || []).empty? || @defer_delivery
|
|
1477
1494
|
|
|
@@ -1488,6 +1505,7 @@ module Cronwatch
|
|
|
1488
1505
|
|
|
1489
1506
|
delivered = []
|
|
1490
1507
|
failed = []
|
|
1508
|
+
sent = []
|
|
1491
1509
|
claimed.each do |alert|
|
|
1492
1510
|
left = @retry_budget_ms - budget.spent_ms
|
|
1493
1511
|
if left <= 0
|
|
@@ -1500,21 +1518,25 @@ module Cronwatch
|
|
|
1500
1518
|
# An alert queued by a process that delivers at check time was never
|
|
1501
1519
|
# triaged. One that was tried (triage: null) is not tried again.
|
|
1502
1520
|
add_triage(alert, [@triage_timeout_ms, left].min) if @triage && alert.type != :recovered && !alert.triage_tried?
|
|
1503
|
-
|
|
1521
|
+
left, took = deliver(alert)
|
|
1522
|
+
sent << Evaluate.without_owed(alert) if took
|
|
1523
|
+
left ? failed << left : delivered << alert
|
|
1504
1524
|
budget.spent_ms += [0, ((AbortSignal.monotonic - started) * 1000).round].max
|
|
1505
1525
|
end
|
|
1506
|
-
record_delivery(name, delivered, failed, [], at)
|
|
1507
|
-
|
|
1526
|
+
record_delivery(name, delivered, failed, [], at, !sent.empty?)
|
|
1527
|
+
sent
|
|
1508
1528
|
end
|
|
1509
1529
|
|
|
1510
1530
|
# Mark delivered alerts done, drop stale ones, and keep failed ones for the
|
|
1511
1531
|
# next check, taking them all out of `sending` (Evaluate.record_sent). A
|
|
1512
1532
|
# failed alert replaces its stored copy, so a triage made on this attempt
|
|
1513
|
-
#
|
|
1514
|
-
#
|
|
1515
|
-
|
|
1533
|
+
# and the channels that still owe it are kept. last_alert_at moves when
|
|
1534
|
+
# `reached`, some channel accepted an alert on this attempt. When this
|
|
1535
|
+
# write fails, alerts still in `sending` are retried once their lease
|
|
1536
|
+
# runs out.
|
|
1537
|
+
def record_delivery(name, delivered, failed, dropped, at, reached)
|
|
1516
1538
|
_, trimmed = update_state(name) do |previous|
|
|
1517
|
-
held = Evaluate.record_sent(Evaluate.normalize_state(previous, name), delivered, failed, dropped, at)
|
|
1539
|
+
held = Evaluate.record_sent(Evaluate.normalize_state(previous, name), delivered, failed, dropped, at, reached)
|
|
1518
1540
|
[held.state, held.dropped]
|
|
1519
1541
|
end
|
|
1520
1542
|
report_dropped(name, trimmed)
|
|
@@ -1522,17 +1544,28 @@ module Cronwatch
|
|
|
1522
1544
|
report(e, "recording alert delivery for #{name}")
|
|
1523
1545
|
end
|
|
1524
1546
|
|
|
1525
|
-
# Send to every channel
|
|
1526
|
-
#
|
|
1527
|
-
#
|
|
1528
|
-
#
|
|
1529
|
-
#
|
|
1530
|
-
#
|
|
1547
|
+
# Send to every channel that still owes the alert (Evaluate.owed_channels:
|
|
1548
|
+
# all of them for an alert not sent before), at once, each in its own
|
|
1549
|
+
# thread with its own timeout, each failure reported under its channel's
|
|
1550
|
+
# name. Returns [left, reached]: `left` is the alert with `owed` cut to
|
|
1551
|
+
# the channels that refused it, or nil when none did (or none is left to
|
|
1552
|
+
# owe it); `reached` is whether any channel accepted it. With no
|
|
1553
|
+
# channels at all it counts as delivered. A channel that times out is
|
|
1554
|
+
# left to finish on its own, and nothing more is sent to it until it
|
|
1555
|
+
# has: meanwhile its alerts count as not delivered there, to be retried
|
|
1556
|
+
# by a later check. So a hung channel holds one thread, not one per alert.
|
|
1531
1557
|
def deliver(alert)
|
|
1532
|
-
return true if @alerts.empty?
|
|
1558
|
+
return [nil, true] if @alerts.empty?
|
|
1559
|
+
|
|
1560
|
+
keys = Evaluate.channel_keys(@alerts.map { |channel| channel_name(channel).to_s })
|
|
1561
|
+
owed = Evaluate.owed_channels(alert, keys)
|
|
1562
|
+
return [nil, false] if owed.empty?
|
|
1533
1563
|
|
|
1564
|
+
plain = Evaluate.without_owed(alert)
|
|
1534
1565
|
outcomes = Array.new(@alerts.length)
|
|
1535
1566
|
threads = @alerts.each_with_index.map do |channel, i|
|
|
1567
|
+
next nil unless owed.include?(keys[i])
|
|
1568
|
+
|
|
1536
1569
|
if @sending_lock.synchronize { @abandoned[i]&.alive? }
|
|
1537
1570
|
outcomes[i] = TimeoutError.new("skipped: an earlier alert timed out and is still being sent")
|
|
1538
1571
|
next nil
|
|
@@ -1540,7 +1573,7 @@ module Cronwatch
|
|
|
1540
1573
|
|
|
1541
1574
|
Thread.new do
|
|
1542
1575
|
Thread.current.report_on_exception = false
|
|
1543
|
-
send_to(channel,
|
|
1576
|
+
send_to(channel, plain)
|
|
1544
1577
|
outcomes[i] = true
|
|
1545
1578
|
# Anything the channel raised, SystemStackError and exit included, is
|
|
1546
1579
|
# a failed send: raised again through join, it would replace the
|
|
@@ -1551,6 +1584,7 @@ module Cronwatch
|
|
|
1551
1584
|
end
|
|
1552
1585
|
deadline = AbortSignal.monotonic + (@channel_timeout_ms / 1000.0)
|
|
1553
1586
|
results = threads.each_with_index.map do |thread, i|
|
|
1587
|
+
next nil unless owed.include?(keys[i])
|
|
1554
1588
|
next outcomes[i] if thread.nil?
|
|
1555
1589
|
if thread.join([deadline - AbortSignal.monotonic, 0].max)
|
|
1556
1590
|
# A thread killed from outside leaves no outcome: not sent.
|
|
@@ -1560,10 +1594,14 @@ module Cronwatch
|
|
|
1560
1594
|
@sending_lock.synchronize { @abandoned[i] = thread }
|
|
1561
1595
|
TimeoutError.new("timed out after #{@channel_timeout_ms}ms")
|
|
1562
1596
|
end
|
|
1597
|
+
failed = []
|
|
1563
1598
|
results.each_with_index do |result, i|
|
|
1564
|
-
|
|
1599
|
+
next if result.nil? || result == true
|
|
1600
|
+
|
|
1601
|
+
failed << keys[i]
|
|
1602
|
+
report(result, "alert channel #{channel_name(@alerts[i])}")
|
|
1565
1603
|
end
|
|
1566
|
-
|
|
1604
|
+
[Evaluate.after_send(alert, owed, failed), failed.length < owed.length]
|
|
1567
1605
|
end
|
|
1568
1606
|
|
|
1569
1607
|
# channel.call(alert, context), the context's on_error reporting a problem
|
|
@@ -120,7 +120,7 @@ module Cronwatch
|
|
|
120
120
|
# environment, so under one of them, in production too, RACK_ENV alone
|
|
121
121
|
# reads "development". There it takes CRONWATCH_ENV, APP_ENV, Rails.env,
|
|
122
122
|
# or RAILS_ENV to say development; any other value of RACK_ENV that reads
|
|
123
|
-
# as development ("
|
|
123
|
+
# as development ("dev", "local") still counts, as no server sets it.
|
|
124
124
|
def stated_development?
|
|
125
125
|
return false unless development?
|
|
126
126
|
|