cronwatch 0.14.0 → 0.14.2

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: c5dd574dbbc8737a8bc69218d0001eee51f48c3a736eb9ef41e1633a32f5f6c2
4
- data.tar.gz: a9f090cdd69a8c19b3c60e4ca2682e68eff5f4a88f70476d9a62b379444ac274
3
+ metadata.gz: c7d7828b625c4a8a6e5d82d3c85b89433a76ca0527fdba4818e8ca4c5e19118d
4
+ data.tar.gz: 1f6f92d282de1d64819055756f661bc51e7d3cc1ef878a56e71ff0bd28f8eb6d
5
5
  SHA512:
6
- metadata.gz: dd9d2b10aefac9b694fd8053781003c364c6113800a34ead88db2ed9fbbde32805b5d4d39a29a571ed528e9d11044164e1d1f1dc6899946c1d43647bf233e7a4
7
- data.tar.gz: e00dbf003d44c0c3d280090d06deb00e397edcff50f9cab6bf65f45215f318521701700bf6fd94ec7dd47a835647c6dc18352bb4ffe54ecd9aa205c5723e65d0
6
+ metadata.gz: 7286c73679cc2cefaa7eab00171ec0cfd54adabb2a77ee963a1160f73417dafb5494fcc4135ea22e67a5ec0eec0b98c8c6ac77640621c1584c3ce6af73ff8f2c
7
+ data.tar.gz: e73021ad7c4dd4e6c2399281c5d673911613a2b0e334d4621110912c3b2b2acc7219ae6d3fea4e5056341be9608d34a7758de1b6d80fdf77a2e7f9bf6c4e62da
@@ -410,8 +410,10 @@ module Cronwatch
410
410
  stored = @store.get_run(run.id)
411
411
  return record_over(definition, stored, run, evaluate) if stored
412
412
 
413
+ inserted = run.dup
414
+ inserted.status = :running if evaluate
413
415
  begin
414
- @store.insert_run(run)
416
+ @store.insert_run(inserted)
415
417
  rescue StandardError
416
418
  # Another process recorded it first.
417
419
  again = begin
@@ -425,10 +427,7 @@ module Cronwatch
425
427
  end
426
428
  return [] unless evaluate
427
429
 
428
- update_state(run.job) { |before| [Evaluate.hold_run(Evaluate.on_run_start(definition, run, before, now), run.id), nil] }
429
- return [] if run.status == :running
430
-
431
- finish_run(definition, run, now)
430
+ record_over(definition, inserted, run, true)
432
431
  end
433
432
 
434
433
  # Hands an error to on_error, as a source reports what went wrong. See #report.
@@ -917,8 +916,21 @@ module Cronwatch
917
916
  report(RuntimeError.new("run #{run.id} of #{run.job} belongs to job \"#{stored.job}\"; ignored"), "recording #{run.job}")
918
917
  return []
919
918
  end
920
- return [] if !%i[running timeout].include?(stored.status) || run.status == :running
919
+ return [] unless %i[running timeout].include?(stored.status)
920
+
921
+ if evaluate && stored.status == :running
922
+ update_state(run.job) do |before|
923
+ # Recheck each CAS attempt so a concurrent finish cannot resurrect its marker.
924
+ current = @store.get_run(run.id)
925
+ if current.nil? || current.job != run.job || current.status != :running || (before.pending_runs || []).include?(run.id)
926
+ next [before, nil]
927
+ end
928
+ [Evaluate.hold_run(Evaluate.on_run_start(definition, run, before, now), run.id), nil]
929
+ end
930
+ end
931
+ return [] if run.status == :running
921
932
 
933
+ tracked = (read_state(run.job).pending_runs || []).include?(run.id)
922
934
  late, ignored = claim_finish(run)
923
935
  if ignored
924
936
  report(RuntimeError.new("run #{run.id} of #{run.job} #{ignored}; ignored"), "recording #{run.job}")
@@ -931,7 +943,7 @@ module Cronwatch
931
943
  end
932
944
  return [] if late && run.status != :ok
933
945
 
934
- finish_run(definition, run, now)
946
+ finish_run(definition, run, now, pending_only: tracked && !late)
935
947
  end
936
948
 
937
949
  # Takes a run stored without judging out of the job's pending runs, so a
@@ -1028,13 +1040,14 @@ module Cronwatch
1028
1040
  return "belongs to job \"#{stored.job}\"" if stored.job != run.job
1029
1041
  end
1030
1042
  end
1043
+ tracked = (read_state(run.job).pending_runs || []).include?(run.id)
1031
1044
  late, ignored = claim_finish(run)
1032
1045
  return ignored if ignored
1033
1046
 
1034
1047
  if !judge
1035
1048
  release_unjudged(run)
1036
1049
  elsif !late || run.status == :ok
1037
- finish_run(Serialize.to_stored(definition), run, finished_at)
1050
+ finish_run(Serialize.to_stored(definition), run, finished_at, pending_only: tracked && !late)
1038
1051
  end
1039
1052
  nil
1040
1053
  end
@@ -1250,7 +1263,7 @@ module Cronwatch
1250
1263
  end
1251
1264
  next if run.status == :running || !run.finished_at.is_a?(Numeric) || run.finished_at > at - Evaluate::SEND_LEASE_MS
1252
1265
 
1253
- alerts.concat(finish_run(stored.definition, run, at))
1266
+ alerts.concat(finish_run(stored.definition, run, at, pending_only: true))
1254
1267
  rescue StandardError => e
1255
1268
  report(e, "evaluating run #{id} of #{stored.name}")
1256
1269
  end
@@ -1260,11 +1273,12 @@ module Cronwatch
1260
1273
  # Evaluate a finished run (ok, failed, or timed out by a check), already
1261
1274
  # written, against the job's state and send what that produces. The
1262
1275
  # alerts are written with that state (see outbox). Never raises.
1263
- def finish_run(definition, run, at)
1276
+ def finish_run(definition, run, at, pending_only: false)
1264
1277
  held = nil
1265
1278
  begin
1266
1279
  past = nil
1267
1280
  _, held = update_state(run.job) do |previous|
1281
+ next [previous, Outbox.new([], 0)] if pending_only && !(previous.pending_runs || []).include?(run.id)
1268
1282
  past ||= history(run)
1269
1283
  before = Evaluate.release_run(previous, run.id)
1270
1284
  outbox(Evaluate.apply_silence(before, Evaluate.on_run_finish(definition, run, before, past, at), at), definition, at)
@@ -688,7 +688,7 @@ module Cronwatch
688
688
 
689
689
  # Whether a running run has gone on longer than the job's timeout.
690
690
  def stuck?(definition, run, now)
691
- run.status == :running && now - run.started_at > timeout_ms(definition)
691
+ run.status == :running && run.finished_at.nil? && now - run.started_at > timeout_ms(definition)
692
692
  end
693
693
 
694
694
  # While a job is silenced nothing new is recorded as an incident: conditions
@@ -55,20 +55,33 @@ module Cronwatch
55
55
  # name lookup: it can be interrupted from Ruby 3.3. On Ruby 3.2 a lookup
56
56
  # that hangs is cut off only by the client's channel deadline.
57
57
  http.open_timeout = HTTP.remaining(deadline)
58
- http.start do |conn|
58
+ # Net::HTTP's read and write timeouts restart for each socket operation.
59
+ # Bound the entire exchange too, including a status line or headers
60
+ # arriving one byte at a time. The connection is private to this call.
61
+ answer = nil
62
+ Timeout.timeout(HTTP.remaining(deadline), TimeoutError, "The operation was aborted due to timeout") do
63
+ http.start
59
64
  remaining = HTTP.remaining(deadline)
60
- conn.read_timeout = remaining
61
- conn.write_timeout = remaining
62
- conn.request(request) do |response|
65
+ http.read_timeout = remaining
66
+ http.write_timeout = remaining
67
+ http.request(request) do |response|
63
68
  status = response.code.to_i
69
+ answer = Response.new(status: status, body: "".b)
64
70
  # Returning from inside the block leaves the rest unread; the connection closes after.
65
- return Response.new(status: status, body: "") if status >= 200 && status < 300
71
+ return answer if status >= 200 && status < 300
66
72
 
67
- return Response.new(status: status, body: read_body(conn, response, deadline))
73
+ read_body(http, response, deadline, answer.body)
74
+ return answer
68
75
  end
69
76
  end
70
- rescue Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout
77
+ rescue Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout, TimeoutError
78
+ return answer if answer
79
+
71
80
  raise TimeoutError
81
+ ensure
82
+ # Timeout has cancelled its timer before this cleanup runs. A timeout
83
+ # during close must not replace an answer or interrupt socket cleanup.
84
+ http.finish if http&.started?
72
85
  end
73
86
 
74
87
  # A connection that gives up after ten seconds, as the SDK's requests do.
@@ -85,8 +98,7 @@ module Cronwatch
85
98
 
86
99
  # The body, read in chunks until it ends or MAX_BODY bytes have come,
87
100
  # whichever is first; what had come when the deadline passes first.
88
- def read_body(conn, response, deadline)
89
- body = "".b
101
+ def read_body(conn, response, deadline, body)
90
102
  conn.read_timeout = HTTP.remaining(deadline)
91
103
  response.read_body do |chunk|
92
104
  raise TimeoutError if HTTP.monotonic >= deadline
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
 
@@ -109,6 +109,7 @@ module Cronwatch
109
109
  @pending = {}
110
110
  # Runs read before they started, by runid, with when they were first seen.
111
111
  @held = {}
112
+ @retry = {}
112
113
  # Each job's name and definition as last declared, by jobid.
113
114
  @known = {}
114
115
  # The last definition declared for each name, so an unchanged job is not declared again.
@@ -493,10 +494,12 @@ module Cronwatch
493
494
  def record(host, names, row, evaluate, alerts, now)
494
495
  runid = Integer(row["runid"])
495
496
  jobid = Integer(row["jobid"])
497
+ evaluate = @retry.fetch(runid, evaluate)
496
498
  name = @pending[runid] || names[jobid]
497
499
  if name.nil? || (@declared_now && !names.value?(name) && !@declared_now.include?(name))
498
500
  @pending.delete(runid)
499
501
  @held.delete(runid)
502
+ @retry.delete(runid)
500
503
  @retired.delete(name) if name
501
504
  return
502
505
  end
@@ -516,9 +519,11 @@ module Cronwatch
516
519
  begin
517
520
  alerts.concat(host.record_run(run, evaluate: evaluate))
518
521
  rescue StandardError => e
522
+ @retry[runid] = evaluate
519
523
  host.on_error(e, "source pg_cron: run #{runid}")
520
524
  return
521
525
  end
526
+ @retry.delete(runid)
522
527
  if run.status == :running
523
528
  @pending[runid] = name
524
529
  else
@@ -561,7 +566,7 @@ module Cronwatch
561
566
  id = run_id_of(run.id)
562
567
  @pending[id] = run.job if id && watched.include?(run.job)
563
568
  end
564
- open = (@pending.keys + @held.keys).to_set
569
+ open = (@pending.keys + @held.keys + @retry.keys).to_set
565
570
  complete = false
566
571
  MAX_PAGES.times do
567
572
  jobids = names.keys
@@ -585,6 +590,7 @@ module Cronwatch
585
590
  open.each do |runid|
586
591
  @pending.delete(runid)
587
592
  @held.delete(runid)
593
+ @retry.delete(runid)
588
594
  end
589
595
  end
590
596
  end
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Cronwatch
4
4
  # Kept in step with @cronwatch/sdk.
5
- VERSION = "0.14.0"
5
+ VERSION = "0.14.2"
6
6
  end
@@ -264,6 +264,7 @@ module Cronwatch
264
264
  # the URL and every access log. Cross-site posts were refused above.
265
265
  if @token && method == "POST" && path == "/signin"
266
266
  body = read_body(request)
267
+ return html(HTML.message_page("Bad request", "Malformed request body", base), 400) if body.equal?(BAD_BODY)
267
268
  # A body too large is no token at all.
268
269
  sent = body.equal?(TOO_LARGE) ? nil : body["token"]
269
270
  return sign_in_page(base) if sent.nil? || !HTTP.constant_time_equal?(sent, @token)
@@ -341,6 +342,7 @@ module Cronwatch
341
342
  if action == "silence"
342
343
  body = read_body(request)
343
344
  return html(HTML.message_page("Not silenced", "The request was too large.", base), 413) if body.equal?(TOO_LARGE)
345
+ return html(HTML.message_page("Not silenced", "Malformed request body", base), 400) if body.equal?(BAD_BODY)
344
346
 
345
347
  begin
346
348
  duration = silence_duration(body["for"])
@@ -388,6 +390,7 @@ module Cronwatch
388
390
  if rest[2] == "silence"
389
391
  body = read_body(request)
390
392
  return api({ ok: false, error: "Request body too large" }, 413) if body.equal?(TOO_LARGE)
393
+ return api({ ok: false, error: "Malformed request body" }, 400) if body.equal?(BAD_BODY)
391
394
 
392
395
  begin
393
396
  duration = silence_duration(body.key?("for") ? body["for"] : request.query("for"))
@@ -530,7 +533,8 @@ module Cronwatch
530
533
 
531
534
  # A request body past Request::MAX_BODY, which read_body answers with.
532
535
  TOO_LARGE = Object.new.freeze
533
- private_constant :TOO_LARGE
536
+ BAD_BODY = Object.new.freeze
537
+ private_constant :TOO_LARGE, :BAD_BODY
534
538
 
535
539
  # The form fields or JSON object of a request, each value as String(value)
536
540
  # gives it in JavaScript, or TOO_LARGE for a body past Request::MAX_BODY.
@@ -546,13 +550,13 @@ module Cronwatch
546
550
  if json
547
551
  data = JSON.parse(Output.utf8(request.body).delete_prefix(""))
548
552
  return data.to_h { |k, v| [k.to_s, js_string(v)] } if data.is_a?(Hash)
549
- return data.each_with_index.to_h { |v, i| [i.to_s, js_string(v)] } if data.is_a?(Array)
553
+ return BAD_BODY
550
554
  else
551
555
  return request.form.transform_values { |v| form_value(v) }
552
556
  end
553
557
  {}
554
558
  rescue StandardError
555
- {}
559
+ BAD_BODY
556
560
  end
557
561
 
558
562
  # String(value) for a parsed JSON value.
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.0
4
+ version: 0.14.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jon C. Phillips