cronwatch 0.8.0 → 0.10.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 +4 -4
- data/README.md +3 -3
- data/lib/cronwatch/alerts/discord.rb +15 -2
- data/lib/cronwatch/client.rb +233 -94
- data/lib/cronwatch/environment.rb +26 -0
- data/lib/cronwatch/evaluate.rb +121 -3
- data/lib/cronwatch/flight.rb +4 -0
- data/lib/cronwatch/job.rb +5 -2
- data/lib/cronwatch/output.rb +35 -1
- data/lib/cronwatch/pg_cron.rb +63 -14
- data/lib/cronwatch/scheduler.rb +73 -10
- data/lib/cronwatch/stores/active_record.rb +33 -12
- data/lib/cronwatch/stores/memory.rb +20 -5
- data/lib/cronwatch/ticker.rb +12 -0
- data/lib/cronwatch/types.rb +60 -5
- data/lib/cronwatch/version.rb +1 -1
- data/lib/cronwatch/web/app.rb +6 -4
- data/lib/cronwatch/web/html.rb +3 -2
- data/lib/generators/cronwatch/install/install_generator.rb +51 -7
- metadata +1 -1
data/lib/cronwatch/client.rb
CHANGED
|
@@ -25,7 +25,7 @@ module Cronwatch
|
|
|
25
25
|
CHANNEL_TIMEOUT_MS = 15_000
|
|
26
26
|
PRUNE_INTERVAL_MS = 60 * 60_000
|
|
27
27
|
# Undelivered alerts kept per job for retry; the oldest go first.
|
|
28
|
-
MAX_UNDELIVERED =
|
|
28
|
+
MAX_UNDELIVERED = Evaluate::MAX_UNDELIVERED
|
|
29
29
|
# Wall-clock time one check spends retrying undelivered alerts, across
|
|
30
30
|
# every job. Once it is spent the rest wait for the next check.
|
|
31
31
|
RETRY_BUDGET_MS = 20_000
|
|
@@ -198,7 +198,7 @@ module Cronwatch
|
|
|
198
198
|
duration_ms: nil, error: nil, output: nil, metrics: {}, trigger: trigger)
|
|
199
199
|
recorded = false
|
|
200
200
|
begin
|
|
201
|
-
sync(definition)
|
|
201
|
+
sync(definition, confirm: true)
|
|
202
202
|
@store.insert_run(run.dup)
|
|
203
203
|
recorded = true
|
|
204
204
|
rescue StandardError => e
|
|
@@ -232,7 +232,7 @@ module Cronwatch
|
|
|
232
232
|
run.duration_ms = Evaluate.run_duration(started_at, finished_at)
|
|
233
233
|
run.metrics = recorder.metrics
|
|
234
234
|
returned = result.is_a?(String) ? Output.utf8(result) : nil
|
|
235
|
-
run.output = recorder.output ||
|
|
235
|
+
run.output = recorder.output || returned
|
|
236
236
|
|
|
237
237
|
conclude(definition, run, result, error, threw, recorder.expect_text || returned, failure: failure)
|
|
238
238
|
begin
|
|
@@ -316,9 +316,10 @@ module Cronwatch
|
|
|
316
316
|
# alone and reported. A finished run is judged as if it had been wrapped
|
|
317
317
|
# here (expect, failures, duration, budgets) and its output and error are
|
|
318
318
|
# redacted the same way; one finishing after a check marked it timeout is
|
|
319
|
-
# judged only when it succeeded, as RunHandle#finish does.
|
|
320
|
-
#
|
|
321
|
-
#
|
|
319
|
+
# judged only when it succeeded, as RunHandle#finish does. A metric that
|
|
320
|
+
# is not a finite number raises ArgumentError before anything is written,
|
|
321
|
+
# as JobContext#metric does. `evaluate: false` stores it without judging
|
|
322
|
+
# it, for history imported on first sight. Returns the alerts it sent.
|
|
322
323
|
#
|
|
323
324
|
# `run` is a Cronwatch::Run, or a hash of its fields (camelCase or snake_case keys).
|
|
324
325
|
def record_run(run, evaluate: true)
|
|
@@ -326,6 +327,19 @@ module Cronwatch
|
|
|
326
327
|
input = run.is_a?(Run) ? run : Run.from_h(run.is_a?(Hash) ? run.to_h { |k, v| [Naming.camel(k), v] } : run)
|
|
327
328
|
declared = @registry.synchronize { @definitions[input.job] }
|
|
328
329
|
raise ArgumentError, "record_run: job \"#{input.job}\" is not declared; call job first" unless declared
|
|
330
|
+
if input.id.to_s.include?("\0")
|
|
331
|
+
raise ArgumentError, "record_run: run ids cannot contain a NUL character (job \"#{input.job}\")"
|
|
332
|
+
end
|
|
333
|
+
# Refused as JobContext#metric refuses them: a store keeps NaN and Infinity as null.
|
|
334
|
+
unless input.metrics.nil? || input.metrics.is_a?(Hash)
|
|
335
|
+
raise ArgumentError, "record_run: metrics must be a Hash of numbers (job \"#{input.job}\", run \"#{input.id}\")"
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
(input.metrics || {}).each do |metric, value|
|
|
339
|
+
next if value.is_a?(Numeric) && value.real? && JS.finite?(value)
|
|
340
|
+
|
|
341
|
+
raise ArgumentError, "record_run: metric \"#{metric}\" must be a finite number (job \"#{input.job}\", run \"#{input.id}\")"
|
|
342
|
+
end
|
|
329
343
|
|
|
330
344
|
sync(declared)
|
|
331
345
|
run = input.dup
|
|
@@ -340,8 +354,8 @@ module Cronwatch
|
|
|
340
354
|
run.error = unmet
|
|
341
355
|
end
|
|
342
356
|
end
|
|
343
|
-
run.output = Output.
|
|
344
|
-
run.error = Output.
|
|
357
|
+
run.output = Output.redact_and_cap(run.output, @redact) unless run.output.nil?
|
|
358
|
+
run.error = Output.redact_and_cap(run.error, @redact) unless run.error.nil?
|
|
345
359
|
definition = Serialize.to_stored(declared)
|
|
346
360
|
|
|
347
361
|
stored = @store.get_run(run.id)
|
|
@@ -411,16 +425,18 @@ module Cronwatch
|
|
|
411
425
|
def jobs_with_runs(limit = 20)
|
|
412
426
|
after_fork_check
|
|
413
427
|
ensure_ready
|
|
414
|
-
|
|
428
|
+
jobs = stored_jobs
|
|
415
429
|
at = now
|
|
416
430
|
count = clamp_limit(limit, 20, 0)
|
|
417
|
-
|
|
431
|
+
jobs.map { |stored| snapshot(stored, at, count) }
|
|
418
432
|
end
|
|
419
433
|
|
|
434
|
+
# A job's summary, or nil for one the store does not have. One declared
|
|
435
|
+
# here and forgotten elsewhere is written again, as stored_jobs does.
|
|
420
436
|
def job_summary(name)
|
|
421
437
|
ensure_ready
|
|
422
438
|
definition = @registry.synchronize { @definitions[name] }
|
|
423
|
-
sync(definition) if definition
|
|
439
|
+
sync(definition, confirm: true) if definition
|
|
424
440
|
stored = @store.get_job(name)
|
|
425
441
|
return nil unless stored
|
|
426
442
|
|
|
@@ -438,7 +454,8 @@ module Cronwatch
|
|
|
438
454
|
@store.get_run(id)
|
|
439
455
|
end
|
|
440
456
|
|
|
441
|
-
# Stop alerts for a job for a while. State keeps updating underneath.
|
|
457
|
+
# Stop alerts for a job for a while. State keeps updating underneath. The
|
|
458
|
+
# end is a whole millisecond, held at 2**53 - 1 (see Evaluate.silence_end).
|
|
442
459
|
# silence("nightly-report", for: "2h") # or silence("nightly-report", "2h")
|
|
443
460
|
def silence(name, duration = nil, **options)
|
|
444
461
|
unknown = options.keys - SILENCE_OPTIONS
|
|
@@ -447,14 +464,17 @@ module Cronwatch
|
|
|
447
464
|
|
|
448
465
|
duration = options[:for] if duration.nil?
|
|
449
466
|
ms = Duration.parse(duration, "silence duration")
|
|
450
|
-
patch_state(name) { |state| state.silenced_until = now
|
|
467
|
+
patch_state(name) { |state| state.silenced_until = Evaluate.silence_end(now, ms) }
|
|
451
468
|
end
|
|
452
469
|
|
|
453
470
|
def unsilence(name)
|
|
454
471
|
patch_state(name) { |state| state.silenced_until = nil }
|
|
455
472
|
end
|
|
456
473
|
|
|
457
|
-
# Remove a job and its runs from the store. A job still declared in code
|
|
474
|
+
# Remove a job and its runs from the store. A job still declared in code
|
|
475
|
+
# comes back: here on its next run, and in any other process that
|
|
476
|
+
# declares it on its next run there, or at that process's next check or
|
|
477
|
+
# dashboard read.
|
|
458
478
|
def forget(name)
|
|
459
479
|
ensure_ready
|
|
460
480
|
@registry.synchronize do
|
|
@@ -474,6 +494,8 @@ module Cronwatch
|
|
|
474
494
|
after_fork_check
|
|
475
495
|
ms = [5_000, Duration.parse(every, "check interval")].max
|
|
476
496
|
@ticker_lock.synchronize do
|
|
497
|
+
# One whose thread ended (killed from outside) is replaced.
|
|
498
|
+
@ticker = nil if @ticker && !@ticker.alive?
|
|
477
499
|
if @ticker
|
|
478
500
|
unless @ticker_ms == ms
|
|
479
501
|
report(ArgumentError.new("start(#{every.inspect}) ignored: already checking every #{Duration.format(@ticker_ms)}; " \
|
|
@@ -490,7 +512,10 @@ module Cronwatch
|
|
|
490
512
|
end
|
|
491
513
|
@ticker = Ticker.new(ms / 1000.0, @first_tick_s) do
|
|
492
514
|
check
|
|
493
|
-
|
|
515
|
+
# Anything, a source's LoadError or a SystemStackError included, is
|
|
516
|
+
# reported and the next tick comes as usual: the thread must not end
|
|
517
|
+
# silently and leave missed and stuck unchecked in this process.
|
|
518
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
494
519
|
report(e, "check")
|
|
495
520
|
end
|
|
496
521
|
end
|
|
@@ -498,18 +523,23 @@ module Cronwatch
|
|
|
498
523
|
end
|
|
499
524
|
|
|
500
525
|
def stop
|
|
501
|
-
|
|
502
|
-
ticker = @ticker_lock.synchronize do
|
|
503
|
-
current = @ticker
|
|
504
|
-
@ticker = nil
|
|
505
|
-
current
|
|
506
|
-
end
|
|
507
|
-
ticker&.stop
|
|
526
|
+
stop_ticker
|
|
508
527
|
nil
|
|
509
528
|
end
|
|
510
529
|
|
|
530
|
+
# Stop the interval, wait for a check already under way (a failed one is
|
|
531
|
+
# reported by whoever started it), then close the store. Runs being
|
|
532
|
+
# recorded are not waited for.
|
|
511
533
|
def close
|
|
512
|
-
|
|
534
|
+
stop_ticker&.join
|
|
535
|
+
flight = @check_lock.synchronize { @checking }
|
|
536
|
+
if flight && !flight.owner.equal?(Thread.current)
|
|
537
|
+
begin
|
|
538
|
+
flight.value
|
|
539
|
+
rescue StandardError
|
|
540
|
+
nil
|
|
541
|
+
end
|
|
542
|
+
end
|
|
513
543
|
@store.close if @store.respond_to?(:close)
|
|
514
544
|
nil
|
|
515
545
|
end
|
|
@@ -531,12 +561,26 @@ module Cronwatch
|
|
|
531
561
|
|
|
532
562
|
private
|
|
533
563
|
|
|
564
|
+
# Stops the interval thread and returns it, or nil when there was none.
|
|
565
|
+
def stop_ticker
|
|
566
|
+
after_fork_check
|
|
567
|
+
ticker = @ticker_lock.synchronize do
|
|
568
|
+
current = @ticker
|
|
569
|
+
@ticker = nil
|
|
570
|
+
current
|
|
571
|
+
end
|
|
572
|
+
ticker&.stop
|
|
573
|
+
ticker
|
|
574
|
+
end
|
|
575
|
+
|
|
534
576
|
# Locks, the check in flight, the interval thread and the channel and
|
|
535
577
|
# triage threads belong to one process. A forked child (Puma, Unicorn,
|
|
536
578
|
# Sidekiq) starts with fresh ones, so start and check work there.
|
|
537
579
|
def reset_process_state
|
|
538
580
|
@pid = Process.pid
|
|
539
581
|
@locks = {}
|
|
582
|
+
# Each job's turn to write its declaration. See sync.
|
|
583
|
+
@syncing = {}
|
|
540
584
|
@check_lock = Mutex.new
|
|
541
585
|
@checking = nil
|
|
542
586
|
@ticker_lock = Mutex.new
|
|
@@ -642,18 +686,65 @@ module Cronwatch
|
|
|
642
686
|
end
|
|
643
687
|
end
|
|
644
688
|
|
|
645
|
-
|
|
689
|
+
# Writes the declaration of `definition`'s name as it stands, unless the
|
|
690
|
+
# store has it. A handle kept from an earlier declaration writes the one
|
|
691
|
+
# that replaced it, never its own over it, and one forgotten since writes
|
|
692
|
+
# its own. The writes of one name take turns (the name's lock in
|
|
693
|
+
# @syncing, held across the write), so one still under way cannot land
|
|
694
|
+
# after a later one; and a name declared again while its write was under
|
|
695
|
+
# way is still to be written. A name is marked as written only while
|
|
696
|
+
# that same declaration stands, so a forget that lands during the write
|
|
697
|
+
# (deleting the row after it) leaves the name to be written again, as
|
|
698
|
+
# does one forgotten before it.
|
|
699
|
+
#
|
|
700
|
+
# With `confirm`, as a run starts, a name already written is read back:
|
|
701
|
+
# another process may have forgotten the job since, and a job still
|
|
702
|
+
# declared here comes back on its next run.
|
|
703
|
+
def sync(definition, confirm: false)
|
|
646
704
|
ensure_ready
|
|
647
|
-
|
|
705
|
+
name = definition.name
|
|
706
|
+
if @registry.synchronize { @synced.include?(name) }
|
|
707
|
+
return if !confirm || @store.get_job(name)
|
|
708
|
+
|
|
709
|
+
@registry.synchronize { @synced.delete(name) }
|
|
710
|
+
end
|
|
711
|
+
|
|
712
|
+
after_fork_check
|
|
713
|
+
turn = @registry.synchronize { @syncing[name] ||= Mutex.new }
|
|
714
|
+
turn.synchronize do
|
|
715
|
+
standing = @registry.synchronize { @synced.include?(name) ? nil : @definitions.fetch(name, definition) }
|
|
716
|
+
next if standing.nil?
|
|
648
717
|
|
|
649
|
-
|
|
650
|
-
|
|
718
|
+
@store.upsert_job(Serialize.to_stored(standing), now)
|
|
719
|
+
@registry.synchronize { @synced << name if @definitions[name].equal?(standing) }
|
|
720
|
+
end
|
|
721
|
+
end
|
|
722
|
+
|
|
723
|
+
# Every stored job, once each declaration has been written. A job
|
|
724
|
+
# declared here that the store no longer has was forgotten by another
|
|
725
|
+
# process after this one wrote it: it is written again, as its next run
|
|
726
|
+
# would, so it is checked and shown while any process still declares it.
|
|
727
|
+
def stored_jobs
|
|
728
|
+
defined_jobs.each { |definition| sync(definition) }
|
|
729
|
+
jobs = @store.list_jobs
|
|
730
|
+
listed = jobs.to_set(&:name)
|
|
731
|
+
missing = defined_jobs.reject { |definition| listed.include?(definition.name) }
|
|
732
|
+
return jobs if missing.empty?
|
|
733
|
+
|
|
734
|
+
missing.each do |definition|
|
|
735
|
+
name = definition.name
|
|
736
|
+
# Not one forgotten here meanwhile.
|
|
737
|
+
standing = @registry.synchronize { @definitions[name].equal?(definition) && @synced.delete(name) }
|
|
738
|
+
sync(definition) if standing
|
|
739
|
+
end
|
|
740
|
+
@store.list_jobs
|
|
651
741
|
end
|
|
652
742
|
|
|
653
743
|
# Runs the block while holding the job's lock, so two runs (or a run and a
|
|
654
744
|
# check) in this process never read and write the job's state over each
|
|
655
745
|
# other. Other processes are coordinated by update_state instead. Only
|
|
656
|
-
# store reads and writes happen inside; alerts are sent outside it.
|
|
746
|
+
# store reads and writes happen inside; alerts are sent outside it. sync
|
|
747
|
+
# gives a job's declaration writes turns the same way, on a lock of their own.
|
|
657
748
|
def serial(job, &block)
|
|
658
749
|
after_fork_check
|
|
659
750
|
lock = @registry.synchronize { @locks[job] ||= Monitor.new }
|
|
@@ -722,11 +813,6 @@ module Cronwatch
|
|
|
722
813
|
end
|
|
723
814
|
end
|
|
724
815
|
|
|
725
|
-
# Identifies an alert across retries.
|
|
726
|
-
def alert_key(alert)
|
|
727
|
-
"#{alert.type}|#{alert.at}|#{alert.run&.id}"
|
|
728
|
-
end
|
|
729
|
-
|
|
730
816
|
# record_run for a run already stored.
|
|
731
817
|
def record_over(definition, stored, run, evaluate)
|
|
732
818
|
if stored.job != run.job
|
|
@@ -774,11 +860,12 @@ module Cronwatch
|
|
|
774
860
|
end
|
|
775
861
|
|
|
776
862
|
# Sets a finished run's status and error from how it ended, then redacts
|
|
777
|
-
# its output and error. Shared by execute
|
|
863
|
+
# its output and error and caps them, in that order. Shared by execute
|
|
864
|
+
# and RunHandle#finish.
|
|
778
865
|
def conclude(definition, run, result, error, threw, expect_text, failure: nil)
|
|
779
866
|
if threw
|
|
780
867
|
run.status = :failed
|
|
781
|
-
run.error = Output.
|
|
868
|
+
run.error = Output.describe_error(error)
|
|
782
869
|
elsif (problem = failure&.call(result))
|
|
783
870
|
run.status = :failed
|
|
784
871
|
run.error = Output.utf8(problem.to_s)
|
|
@@ -792,9 +879,10 @@ module Cronwatch
|
|
|
792
879
|
end
|
|
793
880
|
end
|
|
794
881
|
# Redacted after the expect check, so a rule can still match what was
|
|
795
|
-
# logged
|
|
796
|
-
|
|
797
|
-
run.
|
|
882
|
+
# logged, and before the cap, so the cut cannot keep half a secret. NULs
|
|
883
|
+
# go last, so not even a custom redact can store one.
|
|
884
|
+
run.output = Output.redact_and_cap(run.output, @redact) unless run.output.nil?
|
|
885
|
+
run.error = Output.redact_and_cap(run.error, @redact) unless run.error.nil?
|
|
798
886
|
end
|
|
799
887
|
|
|
800
888
|
# Writes a finished run and evaluates it. `recorded` says whether its
|
|
@@ -847,7 +935,7 @@ module Cronwatch
|
|
|
847
935
|
duration_ms: nil, error: nil, output: nil, metrics: {}, trigger: trigger)
|
|
848
936
|
recorded = false
|
|
849
937
|
begin
|
|
850
|
-
sync(definition)
|
|
938
|
+
sync(definition, confirm: true)
|
|
851
939
|
@store.insert_run(run.dup)
|
|
852
940
|
recorded = true
|
|
853
941
|
rescue StandardError => e
|
|
@@ -934,13 +1022,14 @@ module Cronwatch
|
|
|
934
1022
|
failed, result, error = RunHandle.read_outcome(outcome)
|
|
935
1023
|
finished_at = now
|
|
936
1024
|
returned = result.is_a?(String) ? Output.utf8(result) : nil
|
|
937
|
-
added = recorder.output ||
|
|
1025
|
+
added = recorder.output || returned
|
|
938
1026
|
run = from.dup
|
|
939
1027
|
run.status = :running
|
|
940
1028
|
run.finished_at = finished_at
|
|
941
1029
|
run.duration_ms = Evaluate.run_duration(from.started_at, finished_at)
|
|
942
1030
|
run.error = nil
|
|
943
|
-
|
|
1031
|
+
# Capped by conclude, after it is redacted.
|
|
1032
|
+
run.output = join_lines(from.output, added)
|
|
944
1033
|
run.metrics = (from.metrics || {}).merge(recorder.metrics)
|
|
945
1034
|
expect_text = join_lines(head, join_lines(from.output, recorder.expect_text || returned))
|
|
946
1035
|
conclude(definition, run, result, error, failed, expect_text)
|
|
@@ -977,7 +1066,7 @@ module Cronwatch
|
|
|
977
1066
|
end
|
|
978
1067
|
|
|
979
1068
|
updated = stored.dup
|
|
980
|
-
updated.output = join_output(stored.output, Output.
|
|
1069
|
+
updated.output = join_output(stored.output, Output.redact_and_cap(lines, @redact)) unless lines.nil?
|
|
981
1070
|
updated.metrics = (stored.metrics || {}).merge(metrics)
|
|
982
1071
|
write_run_if(updated, [:running])
|
|
983
1072
|
rescue StandardError => e
|
|
@@ -991,6 +1080,8 @@ module Cronwatch
|
|
|
991
1080
|
got = id.is_a?(String) ? "#{JS.length16(id)} characters" : id.class.to_s
|
|
992
1081
|
raise ArgumentError, "job \"#{job}\": #{method}() needs a run id of 1 to 200 characters (got #{got})"
|
|
993
1082
|
end
|
|
1083
|
+
# Postgres refuses NUL in text, so no store could hold such an id.
|
|
1084
|
+
raise ArgumentError, "job \"#{job}\": #{method}() cannot take a run id containing a NUL character" if id.include?("\0")
|
|
994
1085
|
return unless id.start_with?(RESERVED_RUN_ID_PREFIX)
|
|
995
1086
|
|
|
996
1087
|
raise ArgumentError, "job \"#{job}\": #{method}() cannot take a run id starting with \"#{RESERVED_RUN_ID_PREFIX}\", " \
|
|
@@ -1012,21 +1103,45 @@ module Cronwatch
|
|
|
1012
1103
|
end
|
|
1013
1104
|
|
|
1014
1105
|
# Evaluate a finished run (ok, failed, or timed out by a check), already
|
|
1015
|
-
# written, against the job's state and send what that produces.
|
|
1106
|
+
# written, against the job's state and send what that produces. The
|
|
1107
|
+
# alerts are written with that state (see outbox). Never raises.
|
|
1016
1108
|
def finish_run(definition, run, at)
|
|
1017
|
-
|
|
1109
|
+
held = nil
|
|
1018
1110
|
begin
|
|
1019
1111
|
past = nil
|
|
1020
|
-
_,
|
|
1112
|
+
_, held = update_state(run.job) do |previous|
|
|
1021
1113
|
past ||= history(run)
|
|
1022
|
-
|
|
1023
|
-
[settled.state, settled.alerts]
|
|
1114
|
+
outbox(Evaluate.apply_silence(previous, Evaluate.on_run_finish(definition, run, previous, past, at), at), definition, at)
|
|
1024
1115
|
end
|
|
1025
1116
|
rescue StandardError => e
|
|
1026
1117
|
report(e, "evaluating #{run.job}")
|
|
1027
1118
|
return []
|
|
1028
1119
|
end
|
|
1029
|
-
|
|
1120
|
+
report_dropped(run.job, held.dropped)
|
|
1121
|
+
dispatch(run.job, held.alerts, at)
|
|
1122
|
+
end
|
|
1123
|
+
|
|
1124
|
+
# Alerts written with the state that opened their conditions, and how
|
|
1125
|
+
# many older ones the queue let go.
|
|
1126
|
+
Outbox = Struct.new(:alerts, :dropped)
|
|
1127
|
+
|
|
1128
|
+
# An evaluation as it is written: its drafts composed into alerts and held
|
|
1129
|
+
# in the same state (Evaluate.hold_alerts), so the write that opens a
|
|
1130
|
+
# condition also keeps its alerts, and a process that stops before
|
|
1131
|
+
# sending them does not lose them. Called inside update_state, so it only
|
|
1132
|
+
# computes. Returns `[state, Outbox]`.
|
|
1133
|
+
def outbox(settled, definition, at)
|
|
1134
|
+
alerts = settled.alerts.map { |draft| Format.compose_alert(draft, definition, at) }
|
|
1135
|
+
held = Evaluate.hold_alerts(settled.state, alerts, now + Evaluate::SEND_LEASE_MS, @defer_delivery)
|
|
1136
|
+
[held.state, Outbox.new(alerts, held.dropped)]
|
|
1137
|
+
end
|
|
1138
|
+
|
|
1139
|
+
# Reports alerts let go because a job's queue was full.
|
|
1140
|
+
def report_dropped(name, dropped)
|
|
1141
|
+
return unless dropped.positive?
|
|
1142
|
+
|
|
1143
|
+
report(RuntimeError.new("#{dropped} undelivered alert#{dropped == 1 ? "" : "s"} for #{name} dropped: " \
|
|
1144
|
+
"only the newest #{Evaluate::MAX_UNDELIVERED} are kept for retry"), "alert queue for #{name}")
|
|
1030
1145
|
end
|
|
1031
1146
|
|
|
1032
1147
|
# The runs before `run`, newest first, with up to BASELINE_WINDOW
|
|
@@ -1055,10 +1170,18 @@ module Cronwatch
|
|
|
1055
1170
|
|
|
1056
1171
|
# Runs that never reported back. One that cannot be judged (its job's
|
|
1057
1172
|
# stored timeout no longer parses, say) is reported and skipped.
|
|
1058
|
-
@store.running_runs.each do |
|
|
1059
|
-
declared = @registry.synchronize { @definitions[
|
|
1060
|
-
definition = declared ? Serialize.to_stored(declared) : @store.get_job(
|
|
1061
|
-
next if definition.nil?
|
|
1173
|
+
@store.running_runs.each do |listed|
|
|
1174
|
+
declared = @registry.synchronize { @definitions[listed.job] }
|
|
1175
|
+
definition = declared ? Serialize.to_stored(declared) : @store.get_job(listed.job)&.definition
|
|
1176
|
+
next if definition.nil?
|
|
1177
|
+
|
|
1178
|
+
evaluable(listed.job, definition)
|
|
1179
|
+
next unless Evaluate.stuck?(definition, listed, at)
|
|
1180
|
+
|
|
1181
|
+
# Read again just before the write: lines and metrics flushed since the
|
|
1182
|
+
# list was read (while earlier stuck runs were sent, say) are kept.
|
|
1183
|
+
run = @store.get_run(listed.id)
|
|
1184
|
+
next if run.nil? || run.status != :running || run.job != listed.job
|
|
1062
1185
|
|
|
1063
1186
|
timeout = Evaluate.timeout_ms(definition)
|
|
1064
1187
|
run.status = :timeout
|
|
@@ -1070,7 +1193,7 @@ module Cronwatch
|
|
|
1070
1193
|
|
|
1071
1194
|
alerts.concat(finish_run(definition, run, at))
|
|
1072
1195
|
rescue StandardError => e
|
|
1073
|
-
report(e, "checking #{
|
|
1196
|
+
report(e, "checking #{listed.job}")
|
|
1074
1197
|
end
|
|
1075
1198
|
|
|
1076
1199
|
# Each job on its own: one that cannot be evaluated (a stored schedule
|
|
@@ -1079,17 +1202,22 @@ module Cronwatch
|
|
|
1079
1202
|
# the others.
|
|
1080
1203
|
jobs = []
|
|
1081
1204
|
retries = RetryBudget.new(0)
|
|
1082
|
-
|
|
1205
|
+
stored_jobs.each do |stored|
|
|
1206
|
+
evaluable(stored.name, stored.definition)
|
|
1083
1207
|
recent = @store.list_runs(stored.name, Evaluate::BASELINE_WINDOW)
|
|
1084
1208
|
next_expected_at = nil
|
|
1085
|
-
state,
|
|
1209
|
+
state, held = update_state(stored.name) do |previous|
|
|
1086
1210
|
evaluation = Evaluate.on_check(stored.definition, stored, recent.first, previous, at)
|
|
1087
1211
|
next_expected_at = evaluation.next_expected_at
|
|
1088
1212
|
settled = Evaluate.apply_silence(previous, evaluation, at)
|
|
1089
|
-
|
|
1213
|
+
# Alerts a process stopped sending part way go back to the retry queue.
|
|
1214
|
+
released = Evaluate.release_sending(settled.state, now)
|
|
1215
|
+
following, out = outbox(Evaluate::Evaluation.new(state: released.state, alerts: settled.alerts), stored.definition, at)
|
|
1216
|
+
[following, Outbox.new(out.alerts, released.dropped + out.dropped)]
|
|
1090
1217
|
end
|
|
1218
|
+
report_dropped(stored.name, held.dropped)
|
|
1091
1219
|
alerts.concat(retry_undelivered(stored.name, state, at, retries))
|
|
1092
|
-
alerts.concat(dispatch(
|
|
1220
|
+
alerts.concat(dispatch(stored.name, held.alerts, at))
|
|
1093
1221
|
jobs << Evaluate.summarize(stored, recent, state, next_expected_at, at)
|
|
1094
1222
|
rescue StandardError => e
|
|
1095
1223
|
report(e, "checking #{stored.name}")
|
|
@@ -1116,6 +1244,7 @@ module Cronwatch
|
|
|
1116
1244
|
recent = []
|
|
1117
1245
|
begin
|
|
1118
1246
|
recent = @store.list_runs(stored.name, [count, Evaluate::BASELINE_WINDOW].max)
|
|
1247
|
+
evaluable(stored.name, stored.definition)
|
|
1119
1248
|
state = read_state(stored.name)
|
|
1120
1249
|
next_expected_at = Evaluate.on_check(stored.definition, stored, recent.first, state, at).next_expected_at
|
|
1121
1250
|
JobWithRuns.new(job: Evaluate.summarize(stored, recent, state, next_expected_at, at), runs: recent.first(count))
|
|
@@ -1125,6 +1254,13 @@ module Cronwatch
|
|
|
1125
1254
|
end
|
|
1126
1255
|
end
|
|
1127
1256
|
|
|
1257
|
+
# Raises for a stored definition that was not a JSON object (see
|
|
1258
|
+
# JobDefinition.from_h), so that one job is reported and shown as
|
|
1259
|
+
# failing, and the others are checked as usual.
|
|
1260
|
+
def evaluable(name, definition)
|
|
1261
|
+
raise ArgumentError, "job \"#{name}\": its stored definition is not a JSON object" if definition.unreadable?
|
|
1262
|
+
end
|
|
1263
|
+
|
|
1128
1264
|
# The summary of a job whose evaluation failed, from whatever can still be read.
|
|
1129
1265
|
def unevaluable(stored, at)
|
|
1130
1266
|
recent = begin
|
|
@@ -1151,27 +1287,25 @@ module Cronwatch
|
|
|
1151
1287
|
state
|
|
1152
1288
|
end
|
|
1153
1289
|
|
|
1154
|
-
#
|
|
1155
|
-
#
|
|
1156
|
-
# the delivery fields are written back,
|
|
1157
|
-
|
|
1158
|
-
|
|
1290
|
+
# Triage and send each alert the outbox holds (see outbox). The state,
|
|
1291
|
+
# with the alerts in it, was saved before this, so a slow channel holds up
|
|
1292
|
+
# nothing else; afterwards only the delivery fields are written back,
|
|
1293
|
+
# onto a fresh read of the state, and the alerts leave `sending`. Triage
|
|
1294
|
+
# is made here, never stored with the held alert: the write that opens a
|
|
1295
|
+
# condition cannot wait for it, and a retry triages an alert that has
|
|
1296
|
+
# none. With deliver: :check the alerts were queued for a check elsewhere
|
|
1297
|
+
# instead.
|
|
1298
|
+
def dispatch(name, alerts, at)
|
|
1299
|
+
return alerts if alerts.empty? || @defer_delivery
|
|
1159
1300
|
|
|
1160
|
-
composed = []
|
|
1161
1301
|
delivered = []
|
|
1162
1302
|
failed = []
|
|
1163
|
-
|
|
1164
|
-
alert
|
|
1165
|
-
|
|
1166
|
-
failed << alert
|
|
1167
|
-
else
|
|
1168
|
-
add_triage(alert, @triage_timeout_ms) if @triage && alert.type != :recovered
|
|
1169
|
-
(deliver(alert) ? delivered : failed) << alert
|
|
1170
|
-
end
|
|
1171
|
-
composed << alert
|
|
1303
|
+
alerts.each do |alert|
|
|
1304
|
+
add_triage(alert, @triage_timeout_ms) if @triage && alert.type != :recovered
|
|
1305
|
+
(deliver(alert) ? delivered : failed) << alert
|
|
1172
1306
|
end
|
|
1173
|
-
record_delivery(
|
|
1174
|
-
|
|
1307
|
+
record_delivery(name, delivered, failed, [], at)
|
|
1308
|
+
alerts
|
|
1175
1309
|
end
|
|
1176
1310
|
|
|
1177
1311
|
# The wall-clock milliseconds one check has spent retrying, across its jobs.
|
|
@@ -1207,24 +1341,16 @@ module Cronwatch
|
|
|
1207
1341
|
end
|
|
1208
1342
|
|
|
1209
1343
|
# Mark delivered alerts done, drop stale ones, and keep failed ones for the
|
|
1210
|
-
# next check
|
|
1211
|
-
#
|
|
1344
|
+
# next check, taking them all out of `sending` (Evaluate.record_sent). A
|
|
1345
|
+
# failed alert replaces its stored copy, so a triage made on this attempt
|
|
1346
|
+
# is kept. last_alert_at moves only on a delivery. When this write fails,
|
|
1347
|
+
# alerts still in `sending` are retried once their lease runs out.
|
|
1212
1348
|
def record_delivery(name, delivered, failed, dropped, at)
|
|
1213
1349
|
_, trimmed = update_state(name) do |previous|
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
retried = failed.to_h { |a| [alert_key(a), a] }
|
|
1217
|
-
kept = state.undelivered.reject { |a| done.include?(alert_key(a)) }.map { |a| retried.fetch(alert_key(a), a) }
|
|
1218
|
-
known = kept.map { |a| alert_key(a) }.to_set
|
|
1219
|
-
kept.concat(failed.reject { |a| known.include?(alert_key(a)) })
|
|
1220
|
-
state.undelivered = kept.last(MAX_UNDELIVERED)
|
|
1221
|
-
state.last_alert_at = at if delivered.any?
|
|
1222
|
-
[state, [0, kept.length - MAX_UNDELIVERED].max]
|
|
1223
|
-
end
|
|
1224
|
-
if trimmed.positive?
|
|
1225
|
-
report(RuntimeError.new("#{trimmed} undelivered alert#{trimmed == 1 ? "" : "s"} for #{name} dropped: " \
|
|
1226
|
-
"only the newest #{MAX_UNDELIVERED} are kept for retry"), "alert queue for #{name}")
|
|
1350
|
+
held = Evaluate.record_sent(Evaluate.normalize_state(previous, name), delivered, failed, dropped, at)
|
|
1351
|
+
[held.state, held.dropped]
|
|
1227
1352
|
end
|
|
1353
|
+
report_dropped(name, trimmed)
|
|
1228
1354
|
rescue StandardError => e
|
|
1229
1355
|
report(e, "recording alert delivery for #{name}")
|
|
1230
1356
|
end
|
|
@@ -1249,14 +1375,20 @@ module Cronwatch
|
|
|
1249
1375
|
Thread.current.report_on_exception = false
|
|
1250
1376
|
send_to(channel, alert)
|
|
1251
1377
|
outcomes[i] = true
|
|
1252
|
-
|
|
1378
|
+
# Anything the channel raised, SystemStackError and exit included, is
|
|
1379
|
+
# a failed send: raised again through join, it would replace the
|
|
1380
|
+
# job's own error and lose the alert.
|
|
1381
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
1253
1382
|
outcomes[i] = e
|
|
1254
1383
|
end
|
|
1255
1384
|
end
|
|
1256
1385
|
deadline = AbortSignal.monotonic + (@channel_timeout_ms / 1000.0)
|
|
1257
1386
|
results = threads.each_with_index.map do |thread, i|
|
|
1258
1387
|
next outcomes[i] if thread.nil?
|
|
1259
|
-
|
|
1388
|
+
if thread.join([deadline - AbortSignal.monotonic, 0].max)
|
|
1389
|
+
# A thread killed from outside leaves no outcome: not sent.
|
|
1390
|
+
next outcomes[i] || RuntimeError.new("the channel's thread ended without sending")
|
|
1391
|
+
end
|
|
1260
1392
|
|
|
1261
1393
|
@sending_lock.synchronize { @abandoned[i] = thread }
|
|
1262
1394
|
TimeoutError.new("timed out after #{@channel_timeout_ms}ms")
|
|
@@ -1311,21 +1443,28 @@ module Cronwatch
|
|
|
1311
1443
|
thread = Thread.new do
|
|
1312
1444
|
Thread.current.report_on_exception = false
|
|
1313
1445
|
outcome = [:ok, @triage.call(context)]
|
|
1314
|
-
|
|
1446
|
+
# Anything triage raised counts as no diagnosis, as a channel's does.
|
|
1447
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
1315
1448
|
outcome = [:error, e]
|
|
1316
1449
|
end
|
|
1317
1450
|
unless thread.join(timeout_ms / 1000.0)
|
|
1318
1451
|
@sending_lock.synchronize { @abandoned[:triage] = thread }
|
|
1319
1452
|
raise TimeoutError, "timed out after #{timeout_ms}ms"
|
|
1320
1453
|
end
|
|
1321
|
-
|
|
1454
|
+
outcome ||= [:error, RuntimeError.new("the triage thread ended without an answer")]
|
|
1455
|
+
return no_triage(alert, signal, outcome[1]) if outcome[0] == :error
|
|
1322
1456
|
|
|
1323
1457
|
diagnosis = outcome[1]
|
|
1324
1458
|
alert.triage_result = diagnosis.is_a?(String) && !diagnosis.empty? ? Output.utf8(diagnosis) : nil
|
|
1325
1459
|
rescue StandardError => e
|
|
1460
|
+
no_triage(alert, signal, e)
|
|
1461
|
+
end
|
|
1462
|
+
|
|
1463
|
+
# A triage that gave nothing: the alert goes without one, and why is reported.
|
|
1464
|
+
def no_triage(alert, signal, error)
|
|
1326
1465
|
signal&.abort!
|
|
1327
1466
|
alert.triage_result = nil
|
|
1328
|
-
report(
|
|
1467
|
+
report(error, "triage for #{alert.job}")
|
|
1329
1468
|
end
|
|
1330
1469
|
|
|
1331
1470
|
# A whole number in range, or the fallback for anything that is not a number.
|
|
@@ -25,5 +25,31 @@ module Cronwatch
|
|
|
25
25
|
def production?
|
|
26
26
|
name == "production"
|
|
27
27
|
end
|
|
28
|
+
|
|
29
|
+
# Whether development or test was named by the app rather than by its
|
|
30
|
+
# server, for the dashboard's own token (Cronwatch::Web). Puma, Unicorn,
|
|
31
|
+
# Thin and rackup set RACK_ENV to "development" when nothing names an
|
|
32
|
+
# environment, so under one of them, in production too, RACK_ENV alone
|
|
33
|
+
# reads "development". There it takes Rails.env, RAILS_ENV or APP_ENV
|
|
34
|
+
# (Sinatra's) to say development; RACK_ENV "test" still counts, as no
|
|
35
|
+
# server sets it.
|
|
36
|
+
def stated_development?
|
|
37
|
+
return false unless development?
|
|
38
|
+
return true if defined?(::Rails) && ::Rails.respond_to?(:env) && !::Rails.env.to_s.empty?
|
|
39
|
+
|
|
40
|
+
stated = [ENV.fetch("RAILS_ENV", nil), ENV.fetch("APP_ENV", nil)].find { |value| value && !value.empty? }
|
|
41
|
+
return DEVELOPMENT.include?(stated) unless stated.nil?
|
|
42
|
+
|
|
43
|
+
name == "test" || !defaulting_server?
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# A server that sets RACK_ENV when it is unset is running this process.
|
|
47
|
+
def defaulting_server?
|
|
48
|
+
return true if defined?(::Puma::Server) || defined?(::Puma::Launcher) || defined?(::Unicorn::HttpServer) ||
|
|
49
|
+
defined?(::Thin::Server) || defined?(::Rackup::Server)
|
|
50
|
+
|
|
51
|
+
# Rack 2 autoloads its server: counted only once it has loaded.
|
|
52
|
+
defined?(::Rack) && ::Rack.const_defined?(:Server, false) && ::Rack.autoload?(:Server).nil?
|
|
53
|
+
end
|
|
28
54
|
end
|
|
29
55
|
end
|