cronwatch 0.13.1 → 0.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
 
data/lib/cronwatch/job.rb CHANGED
@@ -100,7 +100,7 @@ module Cronwatch
100
100
  class RunRecorder
101
101
  # Lines are dropped from the front once the output is well past the cap;
102
102
  # Output.redact_and_cap trims it exactly at the end.
103
- KEEP = 64 * 1024
103
+ KEEP = Output::CAP + Output::REDACT_EDGE + 2
104
104
 
105
105
  attr_reader :context, :signal
106
106
 
@@ -111,7 +111,9 @@ module Cronwatch
111
111
  # dropped from @lines: what expect_text needs once the output runs long.
112
112
  @head = []
113
113
  @head_size = 0
114
- @dropped = false
114
+ @whole = ""
115
+ @tail = ""
116
+ @logged = false
115
117
  @metrics = {}
116
118
  @lock = Mutex.new
117
119
  @signal = AbortSignal.new(timeout_ms)
@@ -121,15 +123,26 @@ module Cronwatch
121
123
  def log(line)
122
124
  length = JS.length16(line)
123
125
  @lock.synchronize do
126
+ separator = @logged ? "\n" : ""
127
+ @logged = true
128
+ if @whole
129
+ @whole = JS.length16(@whole) + separator.length + length <= 2 * Output::CAP ? @whole + separator + line : nil
130
+ end
131
+ @tail = JS.tail16(@tail + separator + JS.tail16(line, Output::CAP), Output::CAP).dup
124
132
  if @head_size < Output::CAP
125
- @head << line
133
+ @head << JS.head16(line, Output::CAP - @head_size).dup
126
134
  @head_size += length + 1
127
135
  end
136
+ line = Output.strip_nul(line)
137
+ length = JS.length16(line)
138
+ if length >= KEEP
139
+ line = JS.tail16(line, KEEP - 1).dup
140
+ length = JS.length16(line)
141
+ end
128
142
  @lines << line
129
143
  @size += length + 1
130
144
  while @size > KEEP && @lines.length > 1
131
145
  @size -= JS.length16(@lines.shift) + 1
132
- @dropped = true
133
146
  end
134
147
  end
135
148
  end
@@ -138,7 +151,7 @@ module Cronwatch
138
151
  @lock.synchronize { @metrics[name] = value }
139
152
  end
140
153
 
141
- # The lines still held (past 64 KB the oldest are let go), joined and not
154
+ # The lines still held (past the bounded redaction window the oldest are let go), joined and not
142
155
  # yet capped: the client redacts them first, then caps them
143
156
  # (Output.redact_and_cap).
144
157
  def output
@@ -152,10 +165,9 @@ module Cronwatch
152
165
  @lock.synchronize do
153
166
  next nil if @lines.empty?
154
167
 
155
- all = @lines.join("\n")
156
- next all if !@dropped && JS.length16(all) <= 2 * Output::CAP
168
+ next @whole if @whole
157
169
 
158
- "#{JS.head16(@head.join("\n"), Output::CAP)}\n#{JS.tail16(all, Output::CAP)}"
170
+ "#{JS.head16(@head.join("\n"), Output::CAP)}\n#{@tail}"
159
171
  end
160
172
  end
161
173