cronwatch 0.14.2 → 0.14.3

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: c7d7828b625c4a8a6e5d82d3c85b89433a76ca0527fdba4818e8ca4c5e19118d
4
- data.tar.gz: 1f6f92d282de1d64819055756f661bc51e7d3cc1ef878a56e71ff0bd28f8eb6d
3
+ metadata.gz: d78436fcdc58bdb265650a95d09bfc6300133aa0d24434cfb575fe3f726bb846
4
+ data.tar.gz: 1c52f21a7b4a2c724c8cfa75932e38b56eade9198f53602718c4d5be78eebe24
5
5
  SHA512:
6
- metadata.gz: 7286c73679cc2cefaa7eab00171ec0cfd54adabb2a77ee963a1160f73417dafb5494fcc4135ea22e67a5ec0eec0b98c8c6ac77640621c1584c3ce6af73ff8f2c
7
- data.tar.gz: e73021ad7c4dd4e6c2399281c5d673911613a2b0e334d4621110912c3b2b2acc7219ae6d3fea4e5056341be9608d34a7758de1b6d80fdf77a2e7f9bf6c4e62da
6
+ metadata.gz: 554b274bd8e3058755b2459778d4addcc6de0d2ab39fef01d47c15837d3dee796837333df8c019eaba223b63505cfe6f7c9ef331d0d0fd8d8bcae0ed1c5d46c1
7
+ data.tar.gz: d199b491b1260aea57e40852d36827455c69f3aec205fec3b33b0df631b432dcabce27e75b5a3d605e72f1c3e63531af2620e71e50726e524467f63664e4cdd3
@@ -29,14 +29,14 @@ module Cronwatch
29
29
  def call(alert)
30
30
  url = @link&.call(alert)
31
31
  title = "#{EMOJI[alert.type]} *#{Slack.escape(alert.title)}*#{url && !url.empty? ? " (<#{Slack.link_target(url)}|open>)" : ""}"
32
- body = JS.head16(Slack.code_block_safe(Slack.escape(alert.message)), 2900)
32
+ body = Slack.cut_escaped(Slack.code_block_safe(Slack.escape(alert.message)), 2900)
33
33
  blocks = [
34
34
  { "type" => "section", "text" => { "type" => "mrkdwn", "text" => title } },
35
35
  { "type" => "section", "text" => { "type" => "mrkdwn", "text" => "```#{body}```" } },
36
36
  ]
37
37
  if alert.triage && !alert.triage.empty?
38
38
  # Its own block, so a long diagnosis cannot push a block past Slack's 3000 character limit.
39
- blocks << { "type" => "section", "text" => { "type" => "mrkdwn", "text" => JS.head16("_Triage:_ #{Slack.escape(alert.triage)}", 3000) } }
39
+ blocks << { "type" => "section", "text" => { "type" => "mrkdwn", "text" => Slack.cut_escaped("_Triage:_ #{Slack.escape(alert.triage)}", 3000) } }
40
40
  end
41
41
  payload = {
42
42
  # The notification fallback is parsed as mrkdwn too, so it is escaped like the blocks.
@@ -52,6 +52,14 @@ module Cronwatch
52
52
  text.gsub("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;")
53
53
  end
54
54
 
55
+ # Escaped text cut to at most `max` UTF-16 units, splitting neither an
56
+ # entity escape wrote nor a surrogate pair.
57
+ def self.cut_escaped(text, max)
58
+ kept = JS.head16(text, max)
59
+ amp = kept.rindex("&")
60
+ amp && !kept.index(";", amp) ? kept[0, amp] : kept
61
+ end
62
+
55
63
  # A link's URL as Slack's <url|text> can hold it: `<`, `>`, and `|`
56
64
  # percent-encoded, which ends neither the link nor its text.
57
65
  def self.link_target(url)
@@ -45,9 +45,13 @@ module Cronwatch
45
45
  # The longest run id, in UTF-16 code units (JavaScript's string length):
46
46
  # what start, resume, and record_run take, and every store holds.
47
47
  MAX_RUN_ID = 200
48
+ # The statuses record_run takes.
49
+ RUN_STATUSES = %w[running ok failed timeout].freeze
50
+ # The error a check gives an import whose finish never landed (Evaluate.stranded?).
51
+ STRANDED_ERROR = "Imported, but its finish was never written; marked as timed out"
48
52
  private_constant :CHANNEL_TIMEOUT_MS, :DEFAULT_OPTIONS, :FORK_LOCK, :HISTORY_MAX, :HISTORY_PAGE, :MAX_RUN_ID,
49
- :PRUNE_INTERVAL_MS, :RESERVED_RUN_ID_PREFIX, :RETRY_BUDGET_MS, :SILENCE_OPTIONS, :STATE_ATTEMPTS,
50
- :TRIAGE_TIMEOUT_MS
53
+ :PRUNE_INTERVAL_MS, :RESERVED_RUN_ID_PREFIX, :RETRY_BUDGET_MS, :RUN_STATUSES, :SILENCE_OPTIONS,
54
+ :STATE_ATTEMPTS, :STRANDED_ERROR, :TRIAGE_TIMEOUT_MS
51
55
 
52
56
  # What execute returns: the recorded run, and the block's own outcome.
53
57
  #
@@ -362,7 +366,8 @@ module Cronwatch
362
366
  # redacted the same way; one finishing after a check marked it timeout is
363
367
  # judged only when it succeeded, as RunHandle#finish does. A metric that
364
368
  # is not a finite number raises ArgumentError before anything is written,
365
- # as JobContext#metric does. `evaluate: false` stores it without judging
369
+ # as JobContext#metric does, and so does a status, start, finish,
370
+ # duration, error, output, or trigger evaluation cannot read. `evaluate: false` stores it without judging
366
371
  # it, for history imported on first sight. Returns the alerts it sent.
367
372
  #
368
373
  # `run` is a Cronwatch::Run, or a hash of its fields (camelCase or snake_case keys).
@@ -379,6 +384,7 @@ module Cronwatch
379
384
  if input.id.include?("\0")
380
385
  raise ArgumentError, "record_run: run ids cannot contain a NUL character (job \"#{input.job}\")"
381
386
  end
387
+ check_record_fields(input)
382
388
  # Refused as JobContext#metric refuses them: a store keeps NaN and Infinity as null.
383
389
  unless input.metrics.nil? || input.metrics.is_a?(Hash)
384
390
  raise ArgumentError, "record_run: metrics must be a Hash of numbers (job \"#{input.job}\", run \"#{input.id}\")"
@@ -410,8 +416,16 @@ module Cronwatch
410
416
  stored = @store.get_run(run.id)
411
417
  return record_over(definition, stored, run, evaluate) if stored
412
418
 
419
+ # An evaluated import stays retryable until its durable evaluation marker
420
+ # is written. A finished one's placeholder has finished_at set to when it
421
+ # was written, so a check can tell one whose finish never landed
422
+ # (Evaluate.stranded?); the finish writes the run's own. One still
423
+ # running keeps finished_at nil, so it times out as any running run does.
413
424
  inserted = run.dup
414
- inserted.status = :running if evaluate
425
+ if evaluate
426
+ inserted.finished_at = now unless inserted.status == :running
427
+ inserted.status = :running
428
+ end
415
429
  begin
416
430
  @store.insert_run(inserted)
417
431
  rescue StandardError
@@ -910,6 +924,23 @@ module Cronwatch
910
924
  end
911
925
  end
912
926
 
927
+ # The fields evaluation reads, checked before record_run writes anything.
928
+ def check_record_fields(input)
929
+ where = "job \"#{input.job}\", run \"#{input.id}\""
930
+ status = input.status
931
+ unless (status.is_a?(Symbol) || status.is_a?(String)) && RUN_STATUSES.include?(status.to_s)
932
+ raise ArgumentError, "record_run: status must be \"running\", \"ok\", \"failed\", or \"timeout\" (#{where})"
933
+ end
934
+ raise ArgumentError, "record_run: started_at must be a finite number (#{where})" unless JS.finite?(input.started_at)
935
+ raise ArgumentError, "record_run: finished_at must be a finite number or nil (#{where})" unless input.finished_at.nil? || JS.finite?(input.finished_at)
936
+ raise ArgumentError, "record_run: duration_ms must be a finite number or nil (#{where})" unless input.duration_ms.nil? || JS.finite?(input.duration_ms)
937
+
938
+ { "error" => input.error, "output" => input.output }.each do |field, value|
939
+ raise ArgumentError, "record_run: #{field} must be a String or nil (#{where})" unless value.nil? || value.is_a?(String)
940
+ end
941
+ raise ArgumentError, "record_run: trigger must be a String (#{where})" unless input.trigger.is_a?(String)
942
+ end
943
+
913
944
  # record_run for a run already stored.
914
945
  def record_over(definition, stored, run, evaluate)
915
946
  if stored.job != run.job
@@ -1348,18 +1379,25 @@ module Cronwatch
1348
1379
  next if definition.nil?
1349
1380
 
1350
1381
  evaluable(listed.job, definition)
1351
- next unless Evaluate.stuck?(definition, listed, at)
1382
+ stranded = Evaluate.stranded?(listed, at)
1383
+ next unless stranded || Evaluate.stuck?(definition, listed, at)
1352
1384
 
1353
1385
  # Read again just before the write: lines and metrics flushed since the
1354
1386
  # list was read (while earlier stuck runs were sent, say) are kept.
1355
1387
  run = @store.get_run(listed.id)
1356
1388
  next if run.nil? || run.status != :running || run.job != listed.job
1357
1389
 
1358
- timeout = Evaluate.timeout_ms(definition)
1359
1390
  run.status = :timeout
1360
- run.finished_at = at
1361
- run.duration_ms = Evaluate.run_duration(run.started_at, at)
1362
- run.error = "Still running after #{Duration.format(timeout)}; marked as timed out"
1391
+ if stranded
1392
+ # Its own status was never written; a late finish of it is judged
1393
+ # as one after a timeout, so a success still recovers.
1394
+ run.duration_ms = Evaluate.run_duration(run.started_at, run.finished_at) if run.duration_ms.nil?
1395
+ run.error = run.error.nil? ? STRANDED_ERROR : "#{STRANDED_ERROR}\n#{run.error}"
1396
+ else
1397
+ run.finished_at = at
1398
+ run.duration_ms = Evaluate.run_duration(run.started_at, at)
1399
+ run.error = "Still running after #{Duration.format(Evaluate.timeout_ms(definition))}; marked as timed out"
1400
+ end
1363
1401
  # Only over a row still running: a finish that landed meanwhile wins.
1364
1402
  next unless write_run_if(run, [:running])
1365
1403
 
@@ -691,6 +691,15 @@ module Cronwatch
691
691
  run.status == :running && run.finished_at.nil? && now - run.started_at > timeout_ms(definition)
692
692
  end
693
693
 
694
+ # Whether a finished import was left as its placeholder: record_run writes
695
+ # one as running, with finished_at set to when it was written, and the
696
+ # finish that follows never landed (the importer stopped, or a store write
697
+ # failed and it was not retried). A check closes one SEND_LEASE_MS after
698
+ # it was written, as it closes a stuck run.
699
+ def stranded?(run, now)
700
+ run.status == :running && !run.finished_at.nil? && now - run.finished_at > SEND_LEASE_MS
701
+ end
702
+
694
703
  # While a job is silenced nothing new is recorded as an incident: conditions
695
704
  # may close (so a job that recovered during the silence shows as healthy) but
696
705
  # none may open, so the first problem after the silence ends alerts normally.
@@ -85,8 +85,11 @@ module Cronwatch
85
85
  end
86
86
 
87
87
  # A connection that gives up after ten seconds, as the SDK's requests do.
88
+ # It goes direct: nil as the proxy address keeps Net::HTTP from taking
89
+ # one from http_proxy or https_proxy, which fetch never reads, so an
90
+ # alert (and the credentials in it) reaches only the channel's own host.
88
91
  def connection(uri)
89
- http = Net::HTTP.new(uri.host, uri.port)
92
+ http = Net::HTTP.new(uri.host, uri.port, nil)
90
93
  http.use_ssl = uri.scheme == "https"
91
94
  http.open_timeout = @timeout
92
95
  http.read_timeout = @timeout
data/lib/cronwatch/job.rb CHANGED
@@ -111,8 +111,14 @@ 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
- @whole = ""
115
- @tail = ""
114
+ # Every line logged while they come to no more than twice the cap
115
+ # (nil after), joined only when read, so a log is not a copy of them all.
116
+ @whole = []
117
+ @whole_size = 0
118
+ # The raw lines behind expect_text's tail, each with its UTF-16 length,
119
+ # joined only when it is read.
120
+ @raw = []
121
+ @raw_size = 0
116
122
  @logged = false
117
123
  @metrics = {}
118
124
  @lock = Mutex.new
@@ -126,9 +132,15 @@ module Cronwatch
126
132
  separator = @logged ? "\n" : ""
127
133
  @logged = true
128
134
  if @whole
129
- @whole = JS.length16(@whole) + separator.length + length <= 2 * Output::CAP ? @whole + separator + line : nil
135
+ @whole_size += separator.length + length
136
+ @whole = @whole_size <= 2 * Output::CAP ? @whole << line.dup : nil
130
137
  end
131
- @tail = JS.tail16(@tail + separator + JS.tail16(line, Output::CAP), Output::CAP).dup
138
+ raw = length > Output::CAP ? JS.tail16(line, Output::CAP).dup : line.dup
139
+ raw_length = length > Output::CAP ? JS.length16(raw) : length
140
+ @raw << [raw, raw_length]
141
+ @raw_size += raw_length + 1
142
+ # Drops the oldest only while what is left still holds the cap.
143
+ @raw_size -= @raw.shift[1] + 1 while @raw.length > 1 && @raw_size - (@raw[0][1] + 1) > Output::CAP
132
144
  if @head_size < Output::CAP
133
145
  @head << JS.head16(line, Output::CAP - @head_size).dup
134
146
  @head_size += length + 1
@@ -165,9 +177,9 @@ module Cronwatch
165
177
  @lock.synchronize do
166
178
  next nil if @lines.empty?
167
179
 
168
- next @whole if @whole
180
+ next @whole.join("\n") if @whole
169
181
 
170
- "#{JS.head16(@head.join("\n"), Output::CAP)}\n#{@tail}"
182
+ "#{JS.head16(@head.join("\n"), Output::CAP)}\n#{JS.tail16(@raw.map(&:first).join("\n"), Output::CAP)}"
171
183
  end
172
184
  end
173
185
 
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Cronwatch
4
4
  # Kept in step with @cronwatch/sdk.
5
- VERSION = "0.14.2"
5
+ VERSION = "0.14.3"
6
6
  end
@@ -204,6 +204,14 @@ module Cronwatch
204
204
  end
205
205
 
206
206
  def call(env)
207
+ status, headers, body = answer(env)
208
+ # A HEAD reads what a GET would, and is answered without the body.
209
+ env["REQUEST_METHOD"].to_s.upcase == "HEAD" ? [status, headers, []] : [status, headers, body]
210
+ end
211
+
212
+ private
213
+
214
+ def answer(env)
207
215
  wants_html = true
208
216
  base = @base_path || env["SCRIPT_NAME"].to_s.sub(%r{/+\z}, "")
209
217
  begin
@@ -228,8 +236,6 @@ module Cronwatch
228
236
  end
229
237
  end
230
238
 
231
- private
232
-
233
239
  def serve(request, path, wants_html, base)
234
240
  cw = client
235
241
  method = request.verb
@@ -308,14 +314,15 @@ module Cronwatch
308
314
  return wants_html ? html(HTML.message_page("Bad request", "The path is not valid.", base), 400) : api({ ok: false, error: "Bad path" }, 400)
309
315
  end
310
316
 
311
- # HTML
312
- if method == "GET" && path == "/"
317
+ # HTML. A HEAD reads what a GET would; call sends it without the body.
318
+ reads = %w[GET HEAD].include?(method)
319
+ if reads && path == "/"
313
320
  entries = cw.jobs_with_runs(BOARD_PAGE_RUNS)
314
321
  now = cw.now
315
322
  runs_by_job = entries.to_h { |entry| [entry.job.name, entry.runs] }
316
323
  return html(HTML.dashboard_page(entries.map(&:job), runs_by_job, now, base, nil, board_lanes(cw, entries, now)))
317
324
  end
318
- if method == "GET" && parts[0] == "jobs" && parts.length == 2
325
+ if reads && parts[0] == "jobs" && parts.length == 2
319
326
  job = cw.job_summary(parts[1])
320
327
  return html(HTML.message_page("No such job", "#{parts[1]} is not in the store.", base), 404) unless job
321
328
 
@@ -363,14 +370,15 @@ module Cronwatch
363
370
 
364
371
  def serve_api(request, method, rest, bearer)
365
372
  cw = client
366
- if method == "GET" && rest.empty?
373
+ reads = %w[GET HEAD].include?(method)
374
+ if reads && rest.empty?
367
375
  return api({ ok: true, library: LIBRARY, language: LANGUAGE, version: VERSION, api: API_VERSION })
368
376
  end
369
- return api({ ok: true, jobs: cw.jobs }) if method == "GET" && rest[0] == "jobs" && rest.length == 1
377
+ return api({ ok: true, jobs: cw.jobs }) if reads && rest[0] == "jobs" && rest.length == 1
370
378
 
371
379
  if rest[0] == "jobs" && rest.length == 2
372
380
  name = rest[1]
373
- if method == "GET"
381
+ if reads
374
382
  job = cw.job_summary(name)
375
383
  return api({ ok: false, error: "No such job" }, 404) unless job
376
384
 
@@ -413,7 +421,7 @@ module Cronwatch
413
421
  end
414
422
  return api({ "ok" => true }.merge(cw.check.to_h)) if %w[GET POST].include?(method)
415
423
  end
416
- if method == "GET" && rest[0] == "runs" && rest.length == 2
424
+ if reads && rest[0] == "runs" && rest.length == 2
417
425
  run = cw.get_run(rest[1])
418
426
  return run ? api({ ok: true, run: run }) : api({ ok: false, error: "No such run" }, 404)
419
427
  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.14.2
4
+ version: 0.14.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jon C. Phillips