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 +4 -4
- data/lib/cronwatch/client.rb +24 -10
- data/lib/cronwatch/evaluate.rb +1 -1
- data/lib/cronwatch/http.rb +21 -9
- data/lib/cronwatch/job.rb +20 -8
- data/lib/cronwatch/pg_cron.rb +7 -1
- data/lib/cronwatch/version.rb +1 -1
- data/lib/cronwatch/web/app.rb +7 -3
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c7d7828b625c4a8a6e5d82d3c85b89433a76ca0527fdba4818e8ca4c5e19118d
|
|
4
|
+
data.tar.gz: 1f6f92d282de1d64819055756f661bc51e7d3cc1ef878a56e71ff0bd28f8eb6d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7286c73679cc2cefaa7eab00171ec0cfd54adabb2a77ee963a1160f73417dafb5494fcc4135ea22e67a5ec0eec0b98c8c6ac77640621c1584c3ce6af73ff8f2c
|
|
7
|
+
data.tar.gz: e73021ad7c4dd4e6c2399281c5d673911613a2b0e334d4621110912c3b2b2acc7219ae6d3fea4e5056341be9608d34a7758de1b6d80fdf77a2e7f9bf6c4e62da
|
data/lib/cronwatch/client.rb
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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 []
|
|
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)
|
data/lib/cronwatch/evaluate.rb
CHANGED
|
@@ -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
|
data/lib/cronwatch/http.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
|
71
|
+
return answer if status >= 200 && status < 300
|
|
66
72
|
|
|
67
|
-
|
|
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 =
|
|
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
|
-
@
|
|
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
|
|
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
|
-
|
|
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#{
|
|
170
|
+
"#{JS.head16(@head.join("\n"), Output::CAP)}\n#{@tail}"
|
|
159
171
|
end
|
|
160
172
|
end
|
|
161
173
|
|
data/lib/cronwatch/pg_cron.rb
CHANGED
|
@@ -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
|
data/lib/cronwatch/version.rb
CHANGED
data/lib/cronwatch/web/app.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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.
|