cronwatch 0.13.0 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5e036977146b656ead881d9668a920e8de3a42a9011284a20e21f839a46590ed
4
- data.tar.gz: d0647d96854893d1bf7932d36ba5732ec50cec4a9b8427dc107e14fb8f11fcc8
3
+ metadata.gz: c5dd574dbbc8737a8bc69218d0001eee51f48c3a736eb9ef41e1633a32f5f6c2
4
+ data.tar.gz: a9f090cdd69a8c19b3c60e4ca2682e68eff5f4a88f70476d9a62b379444ac274
5
5
  SHA512:
6
- metadata.gz: 34ea25633ee722da6ef7949f96b9e26856a4e53d800728308e47c5a07bc5cbef16dacc3a8ba3e771439339b77eed2bdec1af2a7e1b4a54361f8ca17f7d31b9d3
7
- data.tar.gz: 37b1b660cf399b07f9dfdf3366cc5b43b2782b22ff70284cbd642463916f0de278c88ecb740c1ab4632547d609a6e05f56e2dcaaee6ef733bd55bf23bff107b1
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, which a receiver checks with `Rack::Utils.secure_compare("sha256=#{Cronwatch::Alerts::Webhook.signature(secret, raw_body)}", header)`.
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("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;")
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 carries `X-CronWatch-Signature: sha256=<hex>`,
11
- # the HMAC-SHA256 of the raw body (Webhook.signature), so the receiver can verify it. Header
12
- # values are trimmed. A redirect is an error, not followed (the headers
13
- # and the signature would go with it): point the url at where the
14
- # receiver really is.
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[name.to_s] = value.is_a?(String) ? JS.trim(value) : value }
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
- headers["x-cronwatch-signature"] = "sha256=#{Webhook.signature(@secret, body)}"
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
@@ -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 no channel accepted, and prune old runs. Call it from start_checking, a
441
- # scheduled job (Cronwatch::CheckJob), or by hand. Concurrent calls share
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
- (deliver(alert) ? delivered : failed) << alert
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 that no channel accepted last time, once each, oldest
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.
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
- (deliver(alert) ? delivered : failed) << alert
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
- delivered
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
- # is kept. last_alert_at moves only on a delivery. When this write fails,
1514
- # alerts still in `sending` are retried once their lease runs out.
1515
- def record_delivery(name, delivered, failed, dropped, at)
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 at once, each in its own thread with its own
1526
- # timeout. True when at least one accepted it, or there are none. A
1527
- # channel that times out is left to finish on its own, and nothing more
1528
- # is sent to it until it has: meanwhile its alerts count as not
1529
- # delivered there, to be retried by a later check. So a hung channel
1530
- # holds one thread, not one per alert.
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, alert)
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
- report(result, "alert channel #{channel_name(@alerts[i])}") unless result == true
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
- results.include?(true)
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 ("test", "dev") still counts, as no server sets it.
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
 
@@ -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
- # `alerts` added to the undelivered queue: one with the same key as a
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(alerts.reject { |alert| known.include?(alert_key(alert)) })
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. `dropped` counts
167
- # alerts let go past MAX_UNDELIVERED.
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. An entry that is
182
- # not a Hash with an alert is dropped; one without a numeric `until`
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 and stale alerts leave the queue; failed
195
- # ones replace their queued copy, so a triage made on this attempt is
196
- # kept, or join the queue. Every one of them leaves `sending`.
197
- # last_alert_at moves only on a delivery. `dropped` counts alerts let go
198
- # past MAX_UNDELIVERED.
199
- def record_sent(state, delivered, failed, stale, now)
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 delivered.any?
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
- # Called when a run starts. Missed and stuck are about the absence of a run,
426
- # so a run starting closes them without an alert; the recovered message
427
- # waits for a successful finish.
428
- def on_run_start(state)
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. If that
529
- # run is still going, the job is busy, not late; stuck covers one that never ends.
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
@@ -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 (1 MiB): a channel reads 200
15
- # characters of a refusal, and a hostile or broken endpoint could
16
- # otherwise stream gigabytes into the worker within the deadline.
17
- # Reading stops there. A 2xx answer's body is not read at all.
18
- MAX_BODY = 1_048_576
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 an
42
- # empty body, as the SDK's channels treat a body they could not read.
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; "" when the deadline passes 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
 
@@ -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. The walker works on the wall clock and Zone.to_utc takes the
107
- # earlier of a repeated time, so, like croner, asked from inside the hour
108
- # that repeats when clocks go back it can answer with times in the past.
109
- # Its answers are filtered, and a stretch of nothing but past times is
110
- # stepped over an hour at a time. Ported from fireAfter in schedule.ts.
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`. Walks the fires in
133
- # batches, as firesBetween in schedule.ts asks croner for them, and drops
134
- # any that do not move forward (see fire_after).
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, parsed.timezone)
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, counted from the last run when there is one.
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. Nil for a cron that never
211
- # fires again.
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? then (last_run_at || registered_at) + parsed.every_ms
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
- following_fire = fire_after(parsed, started_at)
231
- return nil if following_fire.nil?
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
- following = fire_after(parsed, following_fire)
234
- covers = run_covers?(started_at, following_fire, following) || in_spring_forward_gap?(parsed, started_at, following_fire)
235
- covers ? following : following_fire
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 fire whose local time does not exist
248
- # (02:30 when 02:00 jumps to 03:00) is moved by croner to the same distance
249
- # past the jump (03:30), while vixie cron runs it at the jump itself (03:00).
250
- # A run that starts at or after the jump, and before the first fire after
251
- # it when that fire lies within one gap of it, is taken to cover that fire,
252
- # so neither scheduler's run is reported as missed. A cron that really fires
253
- # at 03:30 that night is treated the same way, which only matters if it also
254
- # ran early by up to an hour.
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
- lookback = 3 * 3_600_000
257
- # Every zone kept its local mean time, with no clock change, in the year 1.
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
- # Only the first fire after the jump can be a moved one; a cron that also
278
- # fires at the jump (every 10 minutes, say) was not moved at all.
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
@@ -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
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Cronwatch
4
4
  # Kept in step with @cronwatch/sdk.
5
- VERSION = "0.13.0"
5
+ VERSION = "0.14.0"
6
6
  end
@@ -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
- path = pathname.start_with?(base) ? pathname[base.length..] : pathname
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 point included.
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, and after the last run once a period for as long as nothing
125
- # runs (the times a missed interval job keeps being asked for); with no
126
- # run yet, from its next expected time.
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
- starts = runs.map(&:started_at).sort
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
- starts.each do |start|
137
- t = start + every
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 = starts.empty? ? job.next_expected_at : starts.last + every
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&.started_at
164
- return safely(nil) { Schedule.expectation(parsed, last, last, grace)&.due_at } unless last.nil?
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
@@ -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
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: cronwatch
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.13.0
4
+ version: 0.14.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jon C. Phillips