cronwatch 0.13.1 → 0.14.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 +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 +68 -33
- data/lib/cronwatch/environment.rb +1 -1
- data/lib/cronwatch/evaluate.rb +174 -31
- data/lib/cronwatch/http.rb +10 -9
- 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 +43 -3
- 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: c5dd574dbbc8737a8bc69218d0001eee51f48c3a736eb9ef41e1633a32f5f6c2
|
|
4
|
+
data.tar.gz: a9f090cdd69a8c19b3c60e4ca2682e68eff5f4a88f70476d9a62b379444ac274
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: dd9d2b10aefac9b694fd8053781003c364c6113800a34ead88db2ed9fbbde32805b5d4d39a29a571ed528e9d11044164e1d1f1dc6899946c1d43647bf233e7a4
|
|
7
|
+
data.tar.gz: e00dbf003d44c0c3d280090d06deb00e397edcff50f9cab6bf65f45215f318521701700bf6fd94ec7dd47a835647c6dc18352bb4ffe54ecd9aa205c5723e65d0
|
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,7 +425,7 @@ 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
431
|
finish_run(definition, run, now)
|
|
@@ -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
|
|
@@ -1074,7 +1082,7 @@ module Cronwatch
|
|
|
1074
1082
|
end
|
|
1075
1083
|
if recorded
|
|
1076
1084
|
begin
|
|
1077
|
-
update_state(name) { |before| [Evaluate.hold_run(Evaluate.on_run_start(before), run.id), nil] }
|
|
1085
|
+
update_state(name) { |before| [Evaluate.hold_run(Evaluate.on_run_start(definition, run, before, now), run.id), nil] }
|
|
1078
1086
|
rescue StandardError => e
|
|
1079
1087
|
report(e, "starting #{name}")
|
|
1080
1088
|
end
|
|
@@ -1451,11 +1459,15 @@ module Cronwatch
|
|
|
1451
1459
|
|
|
1452
1460
|
delivered = []
|
|
1453
1461
|
failed = []
|
|
1462
|
+
reached = false
|
|
1454
1463
|
alerts.each do |alert|
|
|
1455
1464
|
add_triage(alert, @triage_timeout_ms) if @triage && alert.type != :recovered
|
|
1456
|
-
|
|
1465
|
+
# The alert as composed, full run and all; only a retry reads the trimmed copy held in state.
|
|
1466
|
+
left, took = deliver(alert)
|
|
1467
|
+
reached ||= took
|
|
1468
|
+
left ? failed << left : delivered << alert
|
|
1457
1469
|
end
|
|
1458
|
-
record_delivery(name, delivered, failed, [], at)
|
|
1470
|
+
record_delivery(name, delivered, failed, [], at, reached)
|
|
1459
1471
|
alerts
|
|
1460
1472
|
end
|
|
1461
1473
|
|
|
@@ -1464,14 +1476,16 @@ module Cronwatch
|
|
|
1464
1476
|
# @api private
|
|
1465
1477
|
RetryBudget = Struct.new(:spent_ms)
|
|
1466
1478
|
|
|
1467
|
-
# Send the alerts
|
|
1468
|
-
# first
|
|
1469
|
-
#
|
|
1470
|
-
#
|
|
1471
|
-
#
|
|
1472
|
-
#
|
|
1473
|
-
#
|
|
1474
|
-
#
|
|
1479
|
+
# Send the alerts some channel refused last time, once each, oldest
|
|
1480
|
+
# first, to the channels that still owe them (Alert#owed) and no others,
|
|
1481
|
+
# from the copy held in state, its run trimmed (Evaluate.held_run). They
|
|
1482
|
+
# are claimed first (Evaluate.claim_undelivered): moved into `sending`
|
|
1483
|
+
# with a lease, in one conditional write, so when several processes check
|
|
1484
|
+
# at once only the one whose write lands sends them. An alert that no
|
|
1485
|
+
# longer describes the job (Evaluate.stale_alert?) is dropped instead.
|
|
1486
|
+
# Retries across a check share RETRY_BUDGET_MS of wall-clock time;
|
|
1487
|
+
# claimed alerts it has no time left for go back to the queue for the
|
|
1488
|
+
# next check. Returns the alerts some channel took on this attempt.
|
|
1475
1489
|
def retry_undelivered(name, state, at, budget)
|
|
1476
1490
|
return [] if (state.undelivered || []).empty? || @defer_delivery
|
|
1477
1491
|
|
|
@@ -1488,6 +1502,7 @@ module Cronwatch
|
|
|
1488
1502
|
|
|
1489
1503
|
delivered = []
|
|
1490
1504
|
failed = []
|
|
1505
|
+
sent = []
|
|
1491
1506
|
claimed.each do |alert|
|
|
1492
1507
|
left = @retry_budget_ms - budget.spent_ms
|
|
1493
1508
|
if left <= 0
|
|
@@ -1500,21 +1515,25 @@ module Cronwatch
|
|
|
1500
1515
|
# An alert queued by a process that delivers at check time was never
|
|
1501
1516
|
# triaged. One that was tried (triage: null) is not tried again.
|
|
1502
1517
|
add_triage(alert, [@triage_timeout_ms, left].min) if @triage && alert.type != :recovered && !alert.triage_tried?
|
|
1503
|
-
|
|
1518
|
+
left, took = deliver(alert)
|
|
1519
|
+
sent << Evaluate.without_owed(alert) if took
|
|
1520
|
+
left ? failed << left : delivered << alert
|
|
1504
1521
|
budget.spent_ms += [0, ((AbortSignal.monotonic - started) * 1000).round].max
|
|
1505
1522
|
end
|
|
1506
|
-
record_delivery(name, delivered, failed, [], at)
|
|
1507
|
-
|
|
1523
|
+
record_delivery(name, delivered, failed, [], at, !sent.empty?)
|
|
1524
|
+
sent
|
|
1508
1525
|
end
|
|
1509
1526
|
|
|
1510
1527
|
# Mark delivered alerts done, drop stale ones, and keep failed ones for the
|
|
1511
1528
|
# next check, taking them all out of `sending` (Evaluate.record_sent). A
|
|
1512
1529
|
# failed alert replaces its stored copy, so a triage made on this attempt
|
|
1513
|
-
#
|
|
1514
|
-
#
|
|
1515
|
-
|
|
1530
|
+
# and the channels that still owe it are kept. last_alert_at moves when
|
|
1531
|
+
# `reached`, some channel accepted an alert on this attempt. When this
|
|
1532
|
+
# write fails, alerts still in `sending` are retried once their lease
|
|
1533
|
+
# runs out.
|
|
1534
|
+
def record_delivery(name, delivered, failed, dropped, at, reached)
|
|
1516
1535
|
_, trimmed = update_state(name) do |previous|
|
|
1517
|
-
held = Evaluate.record_sent(Evaluate.normalize_state(previous, name), delivered, failed, dropped, at)
|
|
1536
|
+
held = Evaluate.record_sent(Evaluate.normalize_state(previous, name), delivered, failed, dropped, at, reached)
|
|
1518
1537
|
[held.state, held.dropped]
|
|
1519
1538
|
end
|
|
1520
1539
|
report_dropped(name, trimmed)
|
|
@@ -1522,17 +1541,28 @@ module Cronwatch
|
|
|
1522
1541
|
report(e, "recording alert delivery for #{name}")
|
|
1523
1542
|
end
|
|
1524
1543
|
|
|
1525
|
-
# Send to every channel
|
|
1526
|
-
#
|
|
1527
|
-
#
|
|
1528
|
-
#
|
|
1529
|
-
#
|
|
1530
|
-
#
|
|
1544
|
+
# Send to every channel that still owes the alert (Evaluate.owed_channels:
|
|
1545
|
+
# all of them for an alert not sent before), at once, each in its own
|
|
1546
|
+
# thread with its own timeout, each failure reported under its channel's
|
|
1547
|
+
# name. Returns [left, reached]: `left` is the alert with `owed` cut to
|
|
1548
|
+
# the channels that refused it, or nil when none did (or none is left to
|
|
1549
|
+
# owe it); `reached` is whether any channel accepted it. With no
|
|
1550
|
+
# channels at all it counts as delivered. A channel that times out is
|
|
1551
|
+
# left to finish on its own, and nothing more is sent to it until it
|
|
1552
|
+
# has: meanwhile its alerts count as not delivered there, to be retried
|
|
1553
|
+
# by a later check. So a hung channel holds one thread, not one per alert.
|
|
1531
1554
|
def deliver(alert)
|
|
1532
|
-
return true if @alerts.empty?
|
|
1555
|
+
return [nil, true] if @alerts.empty?
|
|
1556
|
+
|
|
1557
|
+
keys = Evaluate.channel_keys(@alerts.map { |channel| channel_name(channel).to_s })
|
|
1558
|
+
owed = Evaluate.owed_channels(alert, keys)
|
|
1559
|
+
return [nil, false] if owed.empty?
|
|
1533
1560
|
|
|
1561
|
+
plain = Evaluate.without_owed(alert)
|
|
1534
1562
|
outcomes = Array.new(@alerts.length)
|
|
1535
1563
|
threads = @alerts.each_with_index.map do |channel, i|
|
|
1564
|
+
next nil unless owed.include?(keys[i])
|
|
1565
|
+
|
|
1536
1566
|
if @sending_lock.synchronize { @abandoned[i]&.alive? }
|
|
1537
1567
|
outcomes[i] = TimeoutError.new("skipped: an earlier alert timed out and is still being sent")
|
|
1538
1568
|
next nil
|
|
@@ -1540,7 +1570,7 @@ module Cronwatch
|
|
|
1540
1570
|
|
|
1541
1571
|
Thread.new do
|
|
1542
1572
|
Thread.current.report_on_exception = false
|
|
1543
|
-
send_to(channel,
|
|
1573
|
+
send_to(channel, plain)
|
|
1544
1574
|
outcomes[i] = true
|
|
1545
1575
|
# Anything the channel raised, SystemStackError and exit included, is
|
|
1546
1576
|
# a failed send: raised again through join, it would replace the
|
|
@@ -1551,6 +1581,7 @@ module Cronwatch
|
|
|
1551
1581
|
end
|
|
1552
1582
|
deadline = AbortSignal.monotonic + (@channel_timeout_ms / 1000.0)
|
|
1553
1583
|
results = threads.each_with_index.map do |thread, i|
|
|
1584
|
+
next nil unless owed.include?(keys[i])
|
|
1554
1585
|
next outcomes[i] if thread.nil?
|
|
1555
1586
|
if thread.join([deadline - AbortSignal.monotonic, 0].max)
|
|
1556
1587
|
# A thread killed from outside leaves no outcome: not sent.
|
|
@@ -1560,10 +1591,14 @@ module Cronwatch
|
|
|
1560
1591
|
@sending_lock.synchronize { @abandoned[i] = thread }
|
|
1561
1592
|
TimeoutError.new("timed out after #{@channel_timeout_ms}ms")
|
|
1562
1593
|
end
|
|
1594
|
+
failed = []
|
|
1563
1595
|
results.each_with_index do |result, i|
|
|
1564
|
-
|
|
1596
|
+
next if result.nil? || result == true
|
|
1597
|
+
|
|
1598
|
+
failed << keys[i]
|
|
1599
|
+
report(result, "alert channel #{channel_name(@alerts[i])}")
|
|
1565
1600
|
end
|
|
1566
|
-
|
|
1601
|
+
[Evaluate.after_send(alert, owed, failed), failed.length < owed.length]
|
|
1567
1602
|
end
|
|
1568
1603
|
|
|
1569
1604
|
# 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
|
|
data/lib/cronwatch/evaluate.rb
CHANGED
|
@@ -146,15 +146,124 @@ module Cronwatch
|
|
|
146
146
|
"#{alert.type}|#{at.is_a?(Numeric) ? JS.number(at) : at}|#{run.id if run.is_a?(Run)}"
|
|
147
147
|
end
|
|
148
148
|
|
|
149
|
-
#
|
|
149
|
+
# How much of a held alert's run error and output is kept: 2048 UTF-16 units of each.
|
|
150
|
+
HELD_TEXT_CAP = 2048
|
|
151
|
+
|
|
152
|
+
# How far past HELD_TEXT_CAP a text may run and still be kept whole:
|
|
153
|
+
# longer than any marker, so a text trimmed once (its kept part and its
|
|
154
|
+
# marker) is never trimmed again, and the count in its marker stays true.
|
|
155
|
+
HELD_TEXT_SLACK = 64
|
|
156
|
+
|
|
157
|
+
# How much of a held alert's message is kept: its first 4096 UTF-16 units.
|
|
158
|
+
HELD_MESSAGE_CAP = 4096
|
|
159
|
+
|
|
160
|
+
# A run as an alert held in a job's state keeps it (`sending` and
|
|
161
|
+
# `undelivered`), so the state stays small however much a run logged.
|
|
162
|
+
# Its error keeps its first HELD_TEXT_CAP UTF-16 units and its output its
|
|
163
|
+
# last, each cut so as not to split a surrogate pair, with a line saying
|
|
164
|
+
# how many units were left out: "<head>\n[N more characters trimmed]" and
|
|
165
|
+
# "[N earlier characters trimmed]\n<tail>". A text of at most
|
|
166
|
+
# HELD_TEXT_CAP + HELD_TEXT_SLACK units, or that is not a String, is kept
|
|
167
|
+
# as it is, and so is every other field. The first send of an alert uses
|
|
168
|
+
# the alert as composed; only a retry reads the held copy.
|
|
169
|
+
def held_run(run)
|
|
170
|
+
error = long?(run.error, HELD_TEXT_CAP) ? keep_head(run.error, HELD_TEXT_CAP) : run.error
|
|
171
|
+
output = long?(run.output, HELD_TEXT_CAP) ? keep_tail(run.output) : run.output
|
|
172
|
+
return run if error.equal?(run.error) && output.equal?(run.output)
|
|
173
|
+
|
|
174
|
+
run.dup.tap do |held|
|
|
175
|
+
held.error = error
|
|
176
|
+
held.output = output
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# An alert as it is held in state: its run trimmed (held_run), and its
|
|
181
|
+
# message, when longer than HELD_MESSAGE_CAP + HELD_TEXT_SLACK units, cut
|
|
182
|
+
# to its first HELD_MESSAGE_CAP like a run's error. Everything else, the
|
|
183
|
+
# title and `owed` included, as it is. The first send uses the alert as
|
|
184
|
+
# composed.
|
|
185
|
+
def held_alert(alert)
|
|
186
|
+
run = alert.run.is_a?(Run) ? held_run(alert.run) : alert.run
|
|
187
|
+
message = long?(alert.message, HELD_MESSAGE_CAP) ? keep_head(alert.message, HELD_MESSAGE_CAP) : alert.message
|
|
188
|
+
return alert if run.equal?(alert.run) && message.equal?(alert.message)
|
|
189
|
+
|
|
190
|
+
alert.dup.tap do |held|
|
|
191
|
+
held.run = run
|
|
192
|
+
held.message = message
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def long?(text, cap)
|
|
197
|
+
text.is_a?(String) && JS.length16(text) > cap + HELD_TEXT_SLACK
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# The first `cap` units, one fewer when the cut would split a pair.
|
|
201
|
+
def keep_head(text, cap)
|
|
202
|
+
head = JS.head16(text, cap)
|
|
203
|
+
"#{head}\n[#{JS.length16(text) - JS.length16(head)} more characters trimmed]"
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# The last HELD_TEXT_CAP units, one fewer when the cut would split a pair.
|
|
207
|
+
def keep_tail(text)
|
|
208
|
+
tail = JS.tail16(text, HELD_TEXT_CAP)
|
|
209
|
+
"[#{JS.length16(text) - JS.length16(tail)} earlier characters trimmed]\n#{tail}"
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# Each configured channel's key, in the order configured: its name, with
|
|
213
|
+
# "#2", "#3", and so on for the second and later channels of the same
|
|
214
|
+
# name, so two webhooks are "webhook" and "webhook#2". A key already
|
|
215
|
+
# given to an earlier channel (a channel named "webhook#2" itself, say)
|
|
216
|
+
# is skipped for the next number up, so every key is different. A held
|
|
217
|
+
# alert names the channels that still owe it by these keys (Alert#owed).
|
|
218
|
+
def channel_keys(names)
|
|
219
|
+
seen = Hash.new(0)
|
|
220
|
+
taken = Set.new
|
|
221
|
+
names.map do |name|
|
|
222
|
+
n = seen[name] + 1
|
|
223
|
+
seen[name] = n
|
|
224
|
+
key = n == 1 ? name : "#{name}##{n}"
|
|
225
|
+
key = "#{name}##{n += 1}" while taken.include?(key)
|
|
226
|
+
taken << key
|
|
227
|
+
key
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# The channels a held alert is still owed by, of those configured now
|
|
232
|
+
# (`configured`, channel_keys order): those in its `owed` that are still
|
|
233
|
+
# configured, or every configured one when it has no `owed` list (an
|
|
234
|
+
# alert held by a release before per-channel delivery, or one never
|
|
235
|
+
# sent). An entry of `owed` that is not a String is ignored. None left
|
|
236
|
+
# means the alert is done.
|
|
237
|
+
def owed_channels(alert, configured)
|
|
238
|
+
owed = alert.owed
|
|
239
|
+
return configured.dup unless owed.is_a?(Array)
|
|
240
|
+
|
|
241
|
+
configured.select { |key| owed.include?(key) }
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# An alert after a send to the channels in `tried`, of which those in
|
|
245
|
+
# `failed` refused it: nil when none is left owing it, else the alert
|
|
246
|
+
# with `owed` set to those that refused, in configured order.
|
|
247
|
+
def after_send(alert, tried, failed)
|
|
248
|
+
owed = tried.select { |key| failed.include?(key) }
|
|
249
|
+
owed.empty? ? nil : alert.with_owed(owed)
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# An alert without its `owed` list, as a channel is given it.
|
|
253
|
+
def without_owed(alert)
|
|
254
|
+
alert.owed? ? alert.with_owed(nil) : alert
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# `alerts` added to the undelivered queue, each with its run trimmed
|
|
258
|
+
# (held_alert) and its `owed` list as given: one with the same key as a
|
|
150
259
|
# queued alert replaces it where it stands, the rest go at the end, and
|
|
151
260
|
# only the newest MAX_UNDELIVERED stay. `dropped` counts those let go.
|
|
152
261
|
def queue_undelivered(state, alerts)
|
|
153
262
|
following = clone_state(state)
|
|
154
|
-
by_key = alerts.to_h { |alert| [alert_key(alert), alert] }
|
|
263
|
+
by_key = alerts.to_h { |alert| [alert_key(alert), held_alert(alert)] }
|
|
155
264
|
queue = following.undelivered.map { |alert| by_key.fetch(alert_key(alert), alert) }
|
|
156
265
|
known = queue.to_set { |alert| alert_key(alert) }
|
|
157
|
-
queue.concat(
|
|
266
|
+
queue.concat(by_key.values.reject { |alert| known.include?(alert_key(alert)) })
|
|
158
267
|
following.undelivered = queue.last(MAX_UNDELIVERED)
|
|
159
268
|
Held.new(state: following, dropped: [0, queue.length - MAX_UNDELIVERED].max)
|
|
160
269
|
end
|
|
@@ -163,14 +272,16 @@ module Cronwatch
|
|
|
163
272
|
# their condition, before any is sent, so a process that stops part way
|
|
164
273
|
# does not lose them: into `sending`, each with its lease ending at
|
|
165
274
|
# `until_at`, when this process sends them, or (deliver: :check) straight
|
|
166
|
-
# into the undelivered queue for a check elsewhere.
|
|
167
|
-
#
|
|
275
|
+
# into the undelivered queue for a check elsewhere. Either way the copy
|
|
276
|
+
# held has its run trimmed (held_alert) and no `owed` list, so every
|
|
277
|
+
# configured channel owes it; the first send uses the alerts as composed.
|
|
278
|
+
# `dropped` counts alerts let go past MAX_UNDELIVERED.
|
|
168
279
|
def hold_alerts(state, alerts, until_at, deferred)
|
|
169
280
|
return Held.new(state: state, dropped: 0) if alerts.empty?
|
|
170
281
|
return queue_undelivered(state, alerts) if deferred
|
|
171
282
|
|
|
172
283
|
following = clone_state(state)
|
|
173
|
-
entries = (following.sending || []) + alerts.map { |alert| { "until" => until_at, "alert" => alert } }
|
|
284
|
+
entries = (following.sending || []) + alerts.map { |alert| { "until" => until_at, "alert" => held_alert(alert) } }
|
|
174
285
|
following.sending = entries.last(MAX_UNDELIVERED)
|
|
175
286
|
Held.new(state: following, dropped: [0, entries.length - MAX_UNDELIVERED].max)
|
|
176
287
|
end
|
|
@@ -178,9 +289,9 @@ module Cronwatch
|
|
|
178
289
|
# Alerts in `sending` whose lease ran out by `now`: the process sending
|
|
179
290
|
# them stopped before it recorded how the send went. They go to the
|
|
180
291
|
# undelivered queue, where the retry sends them (with triage, which is
|
|
181
|
-
# never stored with them here) or drops them as stale
|
|
182
|
-
# not a Hash with an alert is
|
|
183
|
-
# counts as run out.
|
|
292
|
+
# never stored with them here) or drops them as stale, owed by the
|
|
293
|
+
# channels its `owed` names. An entry that is not a Hash with an alert is
|
|
294
|
+
# dropped; one without a numeric `until` counts as run out.
|
|
184
295
|
def release_sending(state, now)
|
|
185
296
|
sending = state.sending.is_a?(Array) ? state.sending : []
|
|
186
297
|
lapsed, held = sending.partition { |entry| !(entry.is_a?(Hash) && entry["until"].is_a?(Numeric) && entry["until"] > now) }
|
|
@@ -191,12 +302,15 @@ module Cronwatch
|
|
|
191
302
|
queue_undelivered(kept, lapsed.filter_map { |entry| sending_alert(entry) })
|
|
192
303
|
end
|
|
193
304
|
|
|
194
|
-
# How a send went. Delivered
|
|
195
|
-
# ones
|
|
196
|
-
#
|
|
197
|
-
#
|
|
198
|
-
#
|
|
199
|
-
|
|
305
|
+
# How a send went. Delivered alerts (no channel owes them any more) and
|
|
306
|
+
# stale ones leave the queue; failed ones (some channel still owes them,
|
|
307
|
+
# named by their `owed`) replace their queued copy, so a triage made on
|
|
308
|
+
# this attempt and the shrunken `owed` are kept, or join the queue. Every
|
|
309
|
+
# one of them leaves `sending`. last_alert_at moves to `now` when
|
|
310
|
+
# `reached` (some channel accepted an alert on this attempt; by default,
|
|
311
|
+
# when any was delivered). `dropped` counts alerts let go past
|
|
312
|
+
# MAX_UNDELIVERED.
|
|
313
|
+
def record_sent(state, delivered, failed, stale, now, reached = delivered.any?)
|
|
200
314
|
following = clone_state(state)
|
|
201
315
|
done = (delivered + stale).to_set { |alert| alert_key(alert) }
|
|
202
316
|
following.undelivered = following.undelivered.reject { |alert| done.include?(alert_key(alert)) }
|
|
@@ -206,7 +320,7 @@ module Cronwatch
|
|
|
206
320
|
alert && sent.include?(alert_key(alert))
|
|
207
321
|
end
|
|
208
322
|
following.sending = held.empty? ? nil : held
|
|
209
|
-
following.last_alert_at = now if
|
|
323
|
+
following.last_alert_at = now if reached
|
|
210
324
|
queue_undelivered(following, failed)
|
|
211
325
|
end
|
|
212
326
|
|
|
@@ -422,25 +536,55 @@ module Cronwatch
|
|
|
422
536
|
negative ? "-#{text}" : text
|
|
423
537
|
end
|
|
424
538
|
|
|
425
|
-
#
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
539
|
+
# When a run finished, or nil while it runs (a run marked timeout has finished).
|
|
540
|
+
def finished_at_of(run)
|
|
541
|
+
run.status != :running && run.finished_at.is_a?(Numeric) ? run.finished_at : nil
|
|
542
|
+
end
|
|
543
|
+
|
|
544
|
+
# Whether `run` brings the schedule up to date at `now`, so missed may
|
|
545
|
+
# close for it: judged with it as the job's last run, as on_check judges,
|
|
546
|
+
# the run the schedule wants next is not yet past its deadline. A run that
|
|
547
|
+
# started before the fire missed opened for does not cover that fire,
|
|
548
|
+
# however late it finishes, and neither does a run a source reports only
|
|
549
|
+
# once it is over whose start was before it. Every run starting now
|
|
550
|
+
# covers it. A job with no schedule, or one that no longer parses, has
|
|
551
|
+
# nothing due, so any run does.
|
|
552
|
+
def catches_up?(definition, run, now)
|
|
553
|
+
schedule = definition.schedule
|
|
554
|
+
return true if schedule.nil? || schedule == ""
|
|
555
|
+
|
|
556
|
+
begin
|
|
557
|
+
exp = Schedule.expectation(Schedule.parse(schedule, definition.timezone), run.started_at, run.started_at,
|
|
558
|
+
grace_ms(definition), finished_at_of(run))
|
|
559
|
+
exp.nil? || now <= exp.deadline
|
|
560
|
+
rescue StandardError
|
|
561
|
+
true
|
|
562
|
+
end
|
|
563
|
+
end
|
|
564
|
+
|
|
565
|
+
# Called when a run starts, or when a run a source reports is recorded.
|
|
566
|
+
# Missed and stuck are about the absence of a run, so a run starting
|
|
567
|
+
# closes them without an alert; the recovered message waits for a
|
|
568
|
+
# successful finish. Missed closes only when the run catches the schedule
|
|
569
|
+
# up (catches_up?), which a run starting now always does.
|
|
570
|
+
def on_run_start(definition, run, state, now)
|
|
429
571
|
next_state = clone_state(state)
|
|
430
|
-
close_condition(next_state, :missed)
|
|
572
|
+
close_condition(next_state, :missed) if catches_up?(definition, run, now)
|
|
431
573
|
close_condition(next_state, :stuck)
|
|
432
574
|
next_state
|
|
433
575
|
end
|
|
434
576
|
|
|
435
577
|
# Called when a run finishes with status ok, failed, or timeout. `history` is
|
|
436
|
-
# the job's earlier runs, newest first, not including this one.
|
|
578
|
+
# the job's earlier runs, newest first, not including this one. Missed
|
|
579
|
+
# closes only when the run catches the schedule up (catches_up?): a run
|
|
580
|
+
# that started before the fire missed opened for leaves it open.
|
|
437
581
|
def on_run_finish(definition, run, state, history, now)
|
|
438
582
|
next_state = clone_state(state)
|
|
439
583
|
alerts = []
|
|
440
584
|
|
|
441
585
|
if run.status == :ok
|
|
442
586
|
next_state.consecutive_failures = 0
|
|
443
|
-
close_condition(next_state, :missed)
|
|
587
|
+
close_condition(next_state, :missed) if catches_up?(definition, run, now)
|
|
444
588
|
close_condition(next_state, :stuck)
|
|
445
589
|
close_condition(next_state, :failed)
|
|
446
590
|
|
|
@@ -481,7 +625,7 @@ module Cronwatch
|
|
|
481
625
|
# failed or timeout
|
|
482
626
|
# Held at the top, as the SDK holds it: a count at the limit stays there.
|
|
483
627
|
next_state.consecutive_failures = [next_state.consecutive_failures + 1, MAX_DURATION_MS].min
|
|
484
|
-
close_condition(next_state, :missed)
|
|
628
|
+
close_condition(next_state, :missed) if catches_up?(definition, run, now)
|
|
485
629
|
threshold = [1, definition.failures_before_alert || 1].max
|
|
486
630
|
condition = run.status == :timeout ? :stuck : :failed
|
|
487
631
|
if next_state.consecutive_failures >= threshold && open_condition(next_state, condition, now)
|
|
@@ -518,15 +662,14 @@ module Cronwatch
|
|
|
518
662
|
parsed = Schedule.parse(schedule, definition.timezone)
|
|
519
663
|
grace = grace_ms(definition)
|
|
520
664
|
last_run_at = last_run&.started_at
|
|
521
|
-
exp = Schedule.expectation(parsed, last_run_at, stored.created_at, grace)
|
|
522
|
-
next_expected_at =
|
|
523
|
-
if parsed.interval? then Schedule.next_fire(parsed, stored.created_at, last_run_at)
|
|
524
|
-
else Schedule.next_fire(parsed, now, nil)
|
|
525
|
-
end
|
|
665
|
+
exp = Schedule.expectation(parsed, last_run_at, stored.created_at, grace, last_run && finished_at_of(last_run))
|
|
666
|
+
next_expected_at = parsed.interval? ? exp.due_at : Schedule.next_fire(parsed, now, nil)
|
|
526
667
|
return CheckEvaluation.new(state: next_state, alerts: alerts, next_expected_at: next_expected_at, due_at: nil) unless exp
|
|
527
668
|
|
|
528
|
-
# An interval's next run is due a period after the last one started
|
|
529
|
-
#
|
|
669
|
+
# An interval's next run is due a period after the last one started, or
|
|
670
|
+
# after it finished when it outlasted the period (Schedule.interval_due).
|
|
671
|
+
# If that run is still going, the job is busy, not late; stuck covers one
|
|
672
|
+
# that never ends.
|
|
530
673
|
if parsed.interval? && last_run&.status == :running
|
|
531
674
|
return CheckEvaluation.new(state: next_state, alerts: alerts, next_expected_at: next_expected_at, due_at: exp.due_at)
|
|
532
675
|
end
|
data/lib/cronwatch/http.rb
CHANGED
|
@@ -11,11 +11,12 @@ module Cronwatch
|
|
|
11
11
|
# @api private
|
|
12
12
|
module HTTP
|
|
13
13
|
TIMEOUT = 10
|
|
14
|
-
# The most of an answer's body read (
|
|
15
|
-
# characters of a refusal
|
|
16
|
-
#
|
|
17
|
-
# Reading stops there. A 2xx
|
|
18
|
-
|
|
14
|
+
# The most of an answer's body read (64 KiB, the SDK's ERROR_READ_MAX),
|
|
15
|
+
# far more than any secret plus the 200 characters of a refusal a channel
|
|
16
|
+
# keeps: a hostile or broken endpoint could otherwise stream gigabytes
|
|
17
|
+
# into the worker within the deadline. Reading stops there. A 2xx
|
|
18
|
+
# answer's body is not read at all.
|
|
19
|
+
MAX_BODY = 64 * 1024
|
|
19
20
|
|
|
20
21
|
Response = Struct.new(:status, :body, keyword_init: true) do
|
|
21
22
|
def ok? = status >= 200 && status < 300
|
|
@@ -38,8 +39,8 @@ module Cronwatch
|
|
|
38
39
|
# Header values are sent with the whitespace around them trimmed, as
|
|
39
40
|
# fetch trims them, so a credential read with a trailing newline still
|
|
40
41
|
# sends. Past the deadline before an answer, raises HTTP::TimeoutError;
|
|
41
|
-
# past it while the body is still arriving, returns the answer with
|
|
42
|
-
#
|
|
42
|
+
# past it while the body is still arriving, returns the answer with
|
|
43
|
+
# what of the body had come, as the SDK's errorText keeps it.
|
|
43
44
|
# A 2xx answer comes back with an empty body, unread, since no channel
|
|
44
45
|
# needs it; any other with at most MAX_BODY bytes of it.
|
|
45
46
|
def post(url, body, headers)
|
|
@@ -83,7 +84,7 @@ module Cronwatch
|
|
|
83
84
|
private
|
|
84
85
|
|
|
85
86
|
# The body, read in chunks until it ends or MAX_BODY bytes have come,
|
|
86
|
-
# whichever is first;
|
|
87
|
+
# whichever is first; what had come when the deadline passes first.
|
|
87
88
|
def read_body(conn, response, deadline)
|
|
88
89
|
body = "".b
|
|
89
90
|
conn.read_timeout = HTTP.remaining(deadline)
|
|
@@ -97,7 +98,7 @@ module Cronwatch
|
|
|
97
98
|
end
|
|
98
99
|
body
|
|
99
100
|
rescue Net::ReadTimeout, TimeoutError
|
|
100
|
-
|
|
101
|
+
body
|
|
101
102
|
end
|
|
102
103
|
end
|
|
103
104
|
|
data/lib/cronwatch/schedule.rb
CHANGED
|
@@ -103,12 +103,29 @@ module Cronwatch
|
|
|
103
103
|
end
|
|
104
104
|
|
|
105
105
|
# The first fire strictly after `from`, or nil when the cron never fires
|
|
106
|
-
# again.
|
|
107
|
-
#
|
|
108
|
-
#
|
|
109
|
-
#
|
|
110
|
-
#
|
|
106
|
+
# again. On the night clocks spring forward every slot the pattern names
|
|
107
|
+
# still fires: one whose local time the jump skips is moved forward by the
|
|
108
|
+
# gap's length (see spring_gap and gap_fire_after), so the fires stay
|
|
109
|
+
# strictly ascending whichever instant they are counted from. Ported from
|
|
110
|
+
# fireAfter in schedule.ts.
|
|
111
111
|
def fire_after(parsed, from)
|
|
112
|
+
found = crone_fire_after(parsed, from)
|
|
113
|
+
start = count_from(from)
|
|
114
|
+
return found if start.nil?
|
|
115
|
+
|
|
116
|
+
moved = gap_fire_after(parsed, start)
|
|
117
|
+
!moved.nil? && (found.nil? || moved < found) ? moved : found
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# The first fire strictly after `from` as croner counts it, or nil when
|
|
121
|
+
# the cron never fires again. The walker works on the wall clock and
|
|
122
|
+
# Zone.to_utc takes the earlier of a repeated time, so, like croner, asked
|
|
123
|
+
# from inside the hour that repeats when clocks go back it can answer with
|
|
124
|
+
# times in the past. Its answers are filtered, and a stretch of nothing
|
|
125
|
+
# but past times is stepped over an hour at a time. Asked from inside a
|
|
126
|
+
# spring-forward gap it skips the slots moved out of it, which fire_after
|
|
127
|
+
# adds back. Ported from croneFireAfter in schedule.ts.
|
|
128
|
+
def crone_fire_after(parsed, from)
|
|
112
129
|
raise ArgumentError, "schedule \"#{parsed.source}\" was not made by Schedule.parse" unless parsed.pattern
|
|
113
130
|
return nil if parsed.never
|
|
114
131
|
|
|
@@ -128,10 +145,79 @@ module Cronwatch
|
|
|
128
145
|
nil
|
|
129
146
|
end
|
|
130
147
|
|
|
148
|
+
# A spring-forward jump: `jump_at`, the first instant on the later offset;
|
|
149
|
+
# `gap_ms`, the later offset less the earlier one; `before_ms`, the earlier
|
|
150
|
+
# offset. All in milliseconds.
|
|
151
|
+
Gap = Struct.new(:jump_at, :gap_ms, :before_ms)
|
|
152
|
+
|
|
153
|
+
# How far back a spring-forward jump is looked for: more than any gap a zone has had.
|
|
154
|
+
JUMP_LOOKBACK_MS = 3 * 3_600_000
|
|
155
|
+
|
|
156
|
+
# The window a spring-forward jump moves skipped slots into, when `at`
|
|
157
|
+
# lies in one: from the jump for the gap's length (30 minutes on Lord Howe
|
|
158
|
+
# Island, an hour in most zones). Nil when `at` is not within a gap's
|
|
159
|
+
# length after a jump in the three hours before it.
|
|
160
|
+
def spring_gap(timezone, at)
|
|
161
|
+
jump = spring_jump(timezone, at)
|
|
162
|
+
jump && at < jump.jump_at + jump.gap_ms ? jump : nil
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# The spring-forward jump in the three hours up to `at`, found to the
|
|
166
|
+
# minute: the first minute on the later offset. Nil when the offset at
|
|
167
|
+
# `at` is not ahead of the offset three hours before.
|
|
168
|
+
def spring_jump(timezone, at)
|
|
169
|
+
# Every zone kept its local mean time, with no clock change, in the year 1.
|
|
170
|
+
return nil if at - JUMP_LOOKBACK_MS < Duration::FIRST_DATE_MS || at >= Duration::LAST_DATE_MS
|
|
171
|
+
|
|
172
|
+
after = utc_offset(at, timezone)
|
|
173
|
+
before = utc_offset(at - JUMP_LOOKBACK_MS, timezone)
|
|
174
|
+
return nil if after <= before
|
|
175
|
+
|
|
176
|
+
lo = at - JUMP_LOOKBACK_MS
|
|
177
|
+
hi = at
|
|
178
|
+
while hi - lo > 60_000
|
|
179
|
+
mid = lo + (hi - lo).div(2)
|
|
180
|
+
if utc_offset(mid, timezone) == after then hi = mid
|
|
181
|
+
else lo = mid
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
Gap.new(hi.div(60_000) * 60_000, after - before, before)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# The first slot the pattern names strictly after `at`, the pattern read
|
|
188
|
+
# in UTC, which stands for wall-clock time in gap_fire_after.
|
|
189
|
+
def wall_fire_after(parsed, at)
|
|
190
|
+
next_runs(parsed, 1, at, "UTC").first
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# The first slot moved out of a spring-forward gap strictly after
|
|
194
|
+
# `start`, when `start` lies in that gap's window, else nil. A slot whose
|
|
195
|
+
# local time the jump skips fires at that local time read on the earlier
|
|
196
|
+
# offset, which is the same as moving it forward by the gap's length:
|
|
197
|
+
# 01:20 on the night London skips 01:00 to 02:00 fires at 02:20 BST
|
|
198
|
+
# (01:20Z). The walker moves them the same way when asked from before the
|
|
199
|
+
# jump, as croner does, but not from inside it.
|
|
200
|
+
def gap_fire_after(parsed, start)
|
|
201
|
+
return nil if parsed.never
|
|
202
|
+
|
|
203
|
+
gap = spring_gap(parsed.timezone, start)
|
|
204
|
+
return nil unless gap
|
|
205
|
+
|
|
206
|
+
# Wall-clock time as if it were UTC: the slots the jump skips run from
|
|
207
|
+
# jump_at + before_ms for gap_ms.
|
|
208
|
+
slot = wall_fire_after(parsed, start + gap.before_ms)
|
|
209
|
+
return nil if slot.nil? || slot >= gap.jump_at + gap.before_ms + gap.gap_ms
|
|
210
|
+
|
|
211
|
+
slot - gap.before_ms
|
|
212
|
+
end
|
|
213
|
+
|
|
131
214
|
# Every fire of a cron strictly after `from` and at or before `to`,
|
|
132
|
-
# ascending, or nil when there are more than `limit
|
|
133
|
-
#
|
|
134
|
-
# any that do not move forward
|
|
215
|
+
# ascending, or nil when there are more than `limit`: the fires next_fire
|
|
216
|
+
# gives one after another. Walks the fires in batches, as firesBetween in
|
|
217
|
+
# schedule.ts asks croner for them, and drops any that do not move forward
|
|
218
|
+
# (the hour that repeats when clocks go back, see crone_fire_after).
|
|
219
|
+
# Around a spring-forward gap, where a batch can skip a moved slot or give
|
|
220
|
+
# one out of order, it counts with fire_after instead.
|
|
135
221
|
def fires_between(parsed, from, to, limit)
|
|
136
222
|
raise ArgumentError, "schedule \"#{parsed.source}\" was not made by Schedule.parse" unless parsed.pattern
|
|
137
223
|
return [] if parsed.never
|
|
@@ -143,17 +229,46 @@ module Cronwatch
|
|
|
143
229
|
probe = start
|
|
144
230
|
last = start
|
|
145
231
|
1000.times do
|
|
232
|
+
if spring_gap(parsed.timezone, last)
|
|
233
|
+
# Counting from inside a gap's window: the walker would skip its moved slots.
|
|
234
|
+
t = fire_after(parsed, last)
|
|
235
|
+
return out if t.nil? || t > to
|
|
236
|
+
|
|
237
|
+
out << t
|
|
238
|
+
last = probe = t
|
|
239
|
+
return nil if out.length > limit
|
|
240
|
+
|
|
241
|
+
next
|
|
242
|
+
end
|
|
146
243
|
batch = next_runs(parsed, [limit + 1 - out.length, 24].min, probe)
|
|
147
244
|
return out if batch.empty?
|
|
148
245
|
|
|
246
|
+
stepped = false
|
|
149
247
|
batch.each do |t|
|
|
150
248
|
next if t <= last
|
|
249
|
+
|
|
250
|
+
if spring_gap(parsed.timezone, t)
|
|
251
|
+
# The batch reached a gap's window, where its order is the
|
|
252
|
+
# walker's: the next fire is counted from the last one, and the
|
|
253
|
+
# rest with it.
|
|
254
|
+
following = fire_after(parsed, last)
|
|
255
|
+
return out if following.nil? || following > to
|
|
256
|
+
|
|
257
|
+
out << following
|
|
258
|
+
last = probe = following
|
|
259
|
+
return nil if out.length > limit
|
|
260
|
+
|
|
261
|
+
stepped = true
|
|
262
|
+
break
|
|
263
|
+
end
|
|
151
264
|
return out if t > to
|
|
152
265
|
|
|
153
266
|
out << t
|
|
154
267
|
last = t
|
|
155
268
|
return nil if out.length > limit
|
|
156
269
|
end
|
|
270
|
+
next if stepped
|
|
271
|
+
|
|
157
272
|
finish = batch.last
|
|
158
273
|
probe = finish > probe ? finish : probe + 3_600_000
|
|
159
274
|
end
|
|
@@ -177,7 +292,7 @@ module Cronwatch
|
|
|
177
292
|
# moved back, as runsAfter in schedule.ts asks croner: a time before 400
|
|
178
293
|
# goes forward, into the same local mean time every zone kept then, and
|
|
179
294
|
# one from 2800 goes back, to where the zone's present rules already hold.
|
|
180
|
-
def next_runs(parsed, count, from)
|
|
295
|
+
def next_runs(parsed, count, from, timezone = parsed.timezone)
|
|
181
296
|
from = count_from(from)
|
|
182
297
|
return [] if from.nil?
|
|
183
298
|
|
|
@@ -189,7 +304,7 @@ module Cronwatch
|
|
|
189
304
|
runs = []
|
|
190
305
|
at = from + shift
|
|
191
306
|
count.times do
|
|
192
|
-
at = parsed.pattern.next_after(at,
|
|
307
|
+
at = parsed.pattern.next_after(at, timezone)
|
|
193
308
|
break if at.nil? || at - shift > Duration::LAST_DATE_MS
|
|
194
309
|
|
|
195
310
|
runs << (at - shift)
|
|
@@ -197,25 +312,38 @@ module Cronwatch
|
|
|
197
312
|
runs
|
|
198
313
|
end
|
|
199
314
|
|
|
200
|
-
# The next time the schedule fires strictly after `from`. For an interval,
|
|
315
|
+
# The next time the schedule fires strictly after `from`. For an interval,
|
|
316
|
+
# counted from the last run's start when there is one (see interval_due
|
|
317
|
+
# for a run that outlasted its period).
|
|
201
318
|
def next_fire(parsed, from, last_run_at)
|
|
202
319
|
return (last_run_at || from) + parsed.every_ms if parsed.interval?
|
|
203
320
|
|
|
204
321
|
fire_after(parsed, from)
|
|
205
322
|
end
|
|
206
323
|
|
|
324
|
+
# When an interval of `every_ms` is next due after a run: a period after
|
|
325
|
+
# it started, or, when it finished (`finished_at` not nil) later than
|
|
326
|
+
# that, a period after it finished. A run that outlasts its period is not
|
|
327
|
+
# followed by a missed alert the moment it ends.
|
|
328
|
+
def interval_due(every_ms, started_at, finished_at)
|
|
329
|
+
due = started_at + every_ms
|
|
330
|
+
!finished_at.nil? && finished_at > due ? finished_at + every_ms : due
|
|
331
|
+
end
|
|
332
|
+
|
|
207
333
|
# When the schedule next wants a run, given the last one. For a cron that is
|
|
208
334
|
# the first fire the last run does not already cover; with no run yet, the
|
|
209
335
|
# first fire at or after registration. For an interval it is the last run's
|
|
210
|
-
# start (or registration) plus the interval
|
|
211
|
-
#
|
|
336
|
+
# start (or registration) plus the interval, or its finish plus the
|
|
337
|
+
# interval when it finished (`last_finished_at`, nil while it runs) later
|
|
338
|
+
# than that (interval_due). Nil for a cron that never fires again.
|
|
212
339
|
#
|
|
213
340
|
# Counting forward from the last run, rather than back from now, is what
|
|
214
341
|
# lets a job whose period is shorter than its grace be missed at all, and it
|
|
215
342
|
# works for a cron that fires once a year or less.
|
|
216
|
-
def expectation(parsed, last_run_at, registered_at, grace_ms)
|
|
343
|
+
def expectation(parsed, last_run_at, registered_at, grace_ms, last_finished_at = nil)
|
|
217
344
|
due_at =
|
|
218
|
-
if parsed.interval?
|
|
345
|
+
if parsed.interval?
|
|
346
|
+
last_run_at.nil? ? registered_at + parsed.every_ms : interval_due(parsed.every_ms, last_run_at, last_finished_at)
|
|
219
347
|
elsif last_run_at.nil? then fire_after(parsed, registered_at - 1)
|
|
220
348
|
else due_after_run(parsed, last_run_at)
|
|
221
349
|
end
|
|
@@ -227,12 +355,17 @@ module Cronwatch
|
|
|
227
355
|
# year 1 is due; after 9999 there is none (see count_from).
|
|
228
356
|
def due_after_run(parsed, started_at)
|
|
229
357
|
# A fire at or before the start is covered by the run itself.
|
|
230
|
-
|
|
231
|
-
|
|
358
|
+
due = fire_after(parsed, started_at)
|
|
359
|
+
# Bounded: a gap holds at most a few hours of moved slots.
|
|
360
|
+
100_000.times do
|
|
361
|
+
return nil if due.nil?
|
|
362
|
+
|
|
363
|
+
following = fire_after(parsed, due)
|
|
364
|
+
return due unless run_covers?(started_at, due, following) || in_spring_forward_gap?(parsed, started_at, due)
|
|
232
365
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
366
|
+
due = following
|
|
367
|
+
end
|
|
368
|
+
due
|
|
236
369
|
end
|
|
237
370
|
|
|
238
371
|
# Whether a run starting at `started_at` covers the fire at `due_at`. A minute
|
|
@@ -244,39 +377,21 @@ module Cronwatch
|
|
|
244
377
|
started_at >= due_at - slack
|
|
245
378
|
end
|
|
246
379
|
|
|
247
|
-
# On the night clocks spring forward, a
|
|
248
|
-
# (02:30 when 02:00 jumps to 03:00)
|
|
249
|
-
#
|
|
250
|
-
# A run that starts at or after the jump
|
|
251
|
-
#
|
|
252
|
-
# so neither scheduler's run is reported as missed.
|
|
253
|
-
#
|
|
254
|
-
#
|
|
380
|
+
# On the night clocks spring forward, a slot whose local time does not
|
|
381
|
+
# exist (02:30 when 02:00 jumps to 03:00) fires moved forward by the gap
|
|
382
|
+
# (03:30; see gap_fire_after), while vixie cron runs every skipped job
|
|
383
|
+
# once, at the jump itself (03:00). A run that starts at or after the jump
|
|
384
|
+
# (less a minute of slack), and before such a moved fire, is taken to
|
|
385
|
+
# cover it, so neither scheduler's run is reported as missed. Only a moved
|
|
386
|
+
# slot that is not also a slot of its own on the later offset counts: a
|
|
387
|
+
# cron that fires every 10 minutes moves 02:10 onto its own 03:10, which
|
|
388
|
+
# was not skipped.
|
|
255
389
|
def in_spring_forward_gap?(parsed, started_at, fire_at)
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
return false if fire_at - lookback < Duration::FIRST_DATE_MS
|
|
259
|
-
|
|
260
|
-
after = utc_offset(fire_at, parsed.timezone)
|
|
261
|
-
before = utc_offset(fire_at - lookback, parsed.timezone)
|
|
262
|
-
gap = after - before
|
|
263
|
-
return false if gap <= 0
|
|
264
|
-
|
|
265
|
-
# Find the jump: the first minute in the window with the later offset.
|
|
266
|
-
lo = fire_at - lookback
|
|
267
|
-
hi = fire_at
|
|
268
|
-
while hi - lo > 60_000
|
|
269
|
-
mid = lo + (hi - lo).div(2)
|
|
270
|
-
if utc_offset(mid, parsed.timezone) == after then hi = mid
|
|
271
|
-
else lo = mid
|
|
272
|
-
end
|
|
273
|
-
end
|
|
274
|
-
jump_at = hi.div(60_000) * 60_000
|
|
275
|
-
return false if fire_at - jump_at >= gap || started_at < jump_at - EARLY_SLACK_MS || started_at >= fire_at
|
|
390
|
+
gap = spring_gap(parsed.timezone, fire_at)
|
|
391
|
+
return false if gap.nil? || started_at < gap.jump_at - EARLY_SLACK_MS || started_at >= fire_at
|
|
276
392
|
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
fire_after(parsed, jump_at - 1) == fire_at
|
|
393
|
+
names = ->(at) { wall_fire_after(parsed, at - 1) == at }
|
|
394
|
+
names.call(fire_at + gap.before_ms) && !names.call(fire_at + gap.before_ms + gap.gap_ms)
|
|
280
395
|
end
|
|
281
396
|
|
|
282
397
|
# Milliseconds the zone's wall clock is ahead of UTC at `at`.
|
|
@@ -111,8 +111,8 @@ module Cronwatch
|
|
|
111
111
|
lines << "Job definition: #{JS.json(definition.respond_to?(:to_h) ? definition.to_h : definition)}"
|
|
112
112
|
if run
|
|
113
113
|
lines << ""
|
|
114
|
-
lines << "Triggering run: status #{run.status}, started #{stamp(run.started_at)}, duration #{duration(run)}, trigger #{run.trigger}"
|
|
115
|
-
lines << "Metrics: #{JS.json(run.metrics)}" if run.metrics && !run.metrics.empty?
|
|
114
|
+
lines << "Triggering run: status #{run.status}, started #{stamp(run.started_at)}, duration #{duration(run)}, trigger #{data(run.trigger.to_s)}"
|
|
115
|
+
lines << "Metrics: #{data(JS.json(run.metrics))}" if run.metrics && !run.metrics.empty?
|
|
116
116
|
lines << "Error:\n#{data(JS.head16(run.error, 3000))}" if present?(run.error)
|
|
117
117
|
lines << "Output (tail):\n#{data(JS.tail16(run.output, 3000))}" if present?(run.output)
|
|
118
118
|
end
|
|
@@ -122,7 +122,7 @@ module Cronwatch
|
|
|
122
122
|
lines << "Earlier runs, newest first:"
|
|
123
123
|
earlier.each do |r|
|
|
124
124
|
error = present?(r.error) ? ", error: #{data(JS.head16(r.error.split("\n", -1).first.to_s, 160))}" : ""
|
|
125
|
-
metrics = r.metrics && !r.metrics.empty? ? ", metrics #{JS.json(r.metrics)}" : ""
|
|
125
|
+
metrics = r.metrics && !r.metrics.empty? ? ", metrics #{data(JS.json(r.metrics))}" : ""
|
|
126
126
|
lines << "- #{r.status}, #{stamp(r.started_at)}, #{duration(r)}#{error}#{metrics}"
|
|
127
127
|
end
|
|
128
128
|
end
|
data/lib/cronwatch/types.rb
CHANGED
|
@@ -268,6 +268,27 @@ module Cronwatch
|
|
|
268
268
|
@triage_tried = true
|
|
269
269
|
end
|
|
270
270
|
|
|
271
|
+
# The keys of the channels that still owe a held alert (the SDK's
|
|
272
|
+
# HeldAlert.owed; see Evaluate.channel_keys), as stored, or nil when it
|
|
273
|
+
# has none, so every configured channel owes it. Kept with the fields
|
|
274
|
+
# this version does not otherwise read, so it is written after them, as
|
|
275
|
+
# the SDK's spread of an alert writes it.
|
|
276
|
+
def owed = @extra&.fetch("owed", nil)
|
|
277
|
+
|
|
278
|
+
# Whether the alert carries `owed` at all, whatever it holds.
|
|
279
|
+
def owed? = @extra&.key?("owed") || false
|
|
280
|
+
|
|
281
|
+
# A copy with `owed` set to `keys`, or without it when `keys` is nil.
|
|
282
|
+
def with_owed(keys)
|
|
283
|
+
extra = (@extra || {}).dup
|
|
284
|
+
if keys.nil? then extra.delete("owed")
|
|
285
|
+
else extra["owed"] = keys
|
|
286
|
+
end
|
|
287
|
+
copy = dup
|
|
288
|
+
copy.instance_variable_set(:@extra, extra.empty? ? nil : extra)
|
|
289
|
+
copy
|
|
290
|
+
end
|
|
291
|
+
|
|
271
292
|
# Read leniently, since a queued alert a foreign writer left malformed
|
|
272
293
|
# must affect only its own job: a field of the wrong type is kept as it
|
|
273
294
|
# came (a `type` or `run` that is not what it should be, `details` that
|
data/lib/cronwatch/version.rb
CHANGED
data/lib/cronwatch/web/app.rb
CHANGED
|
@@ -209,6 +209,9 @@ module Cronwatch
|
|
|
209
209
|
begin
|
|
210
210
|
request = Request.new(env)
|
|
211
211
|
path = strip_base(request.pathname, base)
|
|
212
|
+
# Not under the base at all: nothing here answers it, signed in or not.
|
|
213
|
+
return html(HTML.message_page("Not found", request.pathname, base), 404) if path.nil?
|
|
214
|
+
|
|
212
215
|
wants_html = !path.start_with?("/api")
|
|
213
216
|
serve(request, path, wants_html, base)
|
|
214
217
|
rescue StandardError => e
|
|
@@ -481,8 +484,15 @@ module Cronwatch
|
|
|
481
484
|
Digest::SHA256.hexdigest("cronwatch-cookie:#{token}")
|
|
482
485
|
end
|
|
483
486
|
|
|
487
|
+
# The path below the base, or nil when the request is not under it. The
|
|
488
|
+
# pathname, its dot segments already resolved (Request#pathname), must be
|
|
489
|
+
# the base or start with the base and a "/": "/cronwatchapi/jobs" is not
|
|
490
|
+
# under "/cronwatch", and neither is "/cronwatch/../api/jobs" (read as
|
|
491
|
+
# "/api/jobs"). An empty base, for an app mounted at the root, takes every path.
|
|
484
492
|
def strip_base(pathname, base)
|
|
485
|
-
|
|
493
|
+
return nil unless base.empty? || pathname == base || pathname.start_with?("#{base}/")
|
|
494
|
+
|
|
495
|
+
path = pathname[base.length..]
|
|
486
496
|
path = "/" if path.empty?
|
|
487
497
|
path = path[0...-1] if path.length > 1 && path.end_with?("/")
|
|
488
498
|
path
|
|
@@ -650,10 +660,40 @@ module Cronwatch
|
|
|
650
660
|
@env["REQUEST_METHOD"].to_s.upcase
|
|
651
661
|
end
|
|
652
662
|
|
|
653
|
-
# The path as the browser asked for it, still percent-encoded, mount
|
|
663
|
+
# The path as the browser asked for it, still percent-encoded, mount
|
|
664
|
+
# point included, with its dot segments resolved as `new URL` resolves
|
|
665
|
+
# them (a server may pass them through).
|
|
654
666
|
def pathname
|
|
655
667
|
path = "#{@env["SCRIPT_NAME"]}#{@env["PATH_INFO"]}"
|
|
656
|
-
path.empty? ? "/" : path
|
|
668
|
+
path.empty? ? "/" : Request.resolve_dots(path)
|
|
669
|
+
end
|
|
670
|
+
|
|
671
|
+
# A segment the URL standard reads as "." or "..", "%2e" standing for a period.
|
|
672
|
+
SINGLE_DOT = /\A(?:\.|%2e)\z/i
|
|
673
|
+
DOUBLE_DOT = /\A(?:\.|%2e){2}\z/i
|
|
674
|
+
|
|
675
|
+
# `path` with its "." and ".." segments taken out as the URL standard
|
|
676
|
+
# does: ".." takes the segment before it with it, and either one last
|
|
677
|
+
# leaves a trailing slash.
|
|
678
|
+
def self.resolve_dots(path)
|
|
679
|
+
return path unless path.start_with?("/")
|
|
680
|
+
|
|
681
|
+
segments = path.split("/", -1).drop(1)
|
|
682
|
+
return path unless segments.any? { |segment| SINGLE_DOT.match?(segment) || DOUBLE_DOT.match?(segment) }
|
|
683
|
+
|
|
684
|
+
out = []
|
|
685
|
+
segments.each_with_index do |segment, i|
|
|
686
|
+
last = i == segments.length - 1
|
|
687
|
+
if DOUBLE_DOT.match?(segment)
|
|
688
|
+
out.pop
|
|
689
|
+
out << "" if last
|
|
690
|
+
elsif SINGLE_DOT.match?(segment)
|
|
691
|
+
out << "" if last
|
|
692
|
+
else
|
|
693
|
+
out << segment
|
|
694
|
+
end
|
|
695
|
+
end
|
|
696
|
+
"/#{out.join("/")}"
|
|
657
697
|
end
|
|
658
698
|
|
|
659
699
|
def header(name)
|
|
@@ -121,9 +121,10 @@ module Cronwatch
|
|
|
121
121
|
|
|
122
122
|
# When the job was due within `from` to `to`. A cron's fires come from
|
|
123
123
|
# Schedule.fires_between. An interval is due one period after each run
|
|
124
|
-
# started
|
|
125
|
-
#
|
|
126
|
-
#
|
|
124
|
+
# started (after it finished, for a run that outlasted the period:
|
|
125
|
+
# Schedule.interval_due), and after the last run once a period for as
|
|
126
|
+
# long as nothing runs (the times a missed interval job keeps being
|
|
127
|
+
# asked for); with no run yet, from its next expected time.
|
|
127
128
|
def due_times(job, parsed, runs, from, to)
|
|
128
129
|
return DueTimes.new(times: [], dense: false) if parsed.nil?
|
|
129
130
|
|
|
@@ -131,13 +132,14 @@ module Cronwatch
|
|
|
131
132
|
every = parsed.every_ms
|
|
132
133
|
return DueTimes.new(times: [], dense: true) if (to - from).fdiv(every) > MAX_TICKS
|
|
133
134
|
|
|
134
|
-
|
|
135
|
+
# Oldest start first, runs that started together in the order given, as a stable sort leaves them.
|
|
136
|
+
ordered = runs.each_with_index.sort_by { |run, i| [run.started_at, i] }.map(&:first)
|
|
137
|
+
dues = ordered.map { |run| Schedule.interval_due(every, run.started_at, Evaluate.finished_at_of(run)) }
|
|
135
138
|
times = []
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
times << t if t >= from && t <= to
|
|
139
|
+
dues.each do |due|
|
|
140
|
+
times << due if due >= from && due <= to
|
|
139
141
|
end
|
|
140
|
-
t =
|
|
142
|
+
t = dues.empty? ? job.next_expected_at : dues.last
|
|
141
143
|
unless t.nil?
|
|
142
144
|
t += (from - t).fdiv(every).ceil * every if t < from
|
|
143
145
|
while t <= to
|
|
@@ -160,8 +162,10 @@ module Cronwatch
|
|
|
160
162
|
return nil if parsed.nil? || !open?(job, :missed)
|
|
161
163
|
|
|
162
164
|
grace = safely(0) { Evaluate.grace_ms(job.definition) }
|
|
163
|
-
last = job.last_run
|
|
164
|
-
|
|
165
|
+
last = job.last_run
|
|
166
|
+
unless last.nil?
|
|
167
|
+
return safely(nil) { Schedule.expectation(parsed, last.started_at, last.started_at, grace, Evaluate.finished_at_of(last))&.due_at }
|
|
168
|
+
end
|
|
165
169
|
|
|
166
170
|
past = times.select { |t| t + grace < now }
|
|
167
171
|
past.last
|
data/lib/cronwatch/zone.rb
CHANGED
|
@@ -61,6 +61,9 @@ module Cronwatch
|
|
|
61
61
|
guess = target + (target - civil_seconds(wall(target, timezone)))
|
|
62
62
|
seen = wall(guess, timezone)
|
|
63
63
|
if seen == wall
|
|
64
|
+
# Only an hour back is tried, as croner's fromTZ does, so a fall-back
|
|
65
|
+
# of another length (Lord Howe's 30 minutes) keeps croner's answer.
|
|
66
|
+
# Keep it so: Schedule's fall-back fires must match the SDK's.
|
|
64
67
|
earlier = guess - 3600
|
|
65
68
|
return wall(earlier, timezone) == wall ? earlier : guess
|
|
66
69
|
end
|