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.
@@ -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 = 20
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 || (returned && Output.cap(returned))
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. `evaluate:
320
- # false` stores it without judging it, for history imported on first
321
- # sight. Returns the alerts it sent.
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.strip_nul(@redact.call(Output.cap(run.output))) unless run.output.nil?
344
- run.error = Output.strip_nul(@redact.call(Output.cap(run.error))) unless run.error.nil?
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
- defined_jobs.each { |definition| sync(definition) }
428
+ jobs = stored_jobs
415
429
  at = now
416
430
  count = clamp_limit(limit, 20, 0)
417
- @store.list_jobs.map { |stored| snapshot(stored, at, count) }
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 + ms }
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 comes back on its next run.
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
- rescue StandardError => e
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
- after_fork_check
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
- stop
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
- def sync(definition)
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
- return if @registry.synchronize { @synced.include?(definition.name) }
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
- @store.upsert_job(Serialize.to_stored(definition), now)
650
- @registry.synchronize { @synced << definition.name }
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 and RunHandle#finish.
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.error_message(error)
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. NULs go last, so not even a custom redact can store one.
796
- run.output = Output.strip_nul(@redact.call(run.output)) unless run.output.nil?
797
- run.error = Output.strip_nul(@redact.call(run.error)) unless run.error.nil?
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 || (returned && Output.cap(returned))
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
- run.output = join_output(from.output, added)
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.strip_nul(@redact.call(lines))) unless lines.nil?
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. Never raises.
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
- drafts = nil
1109
+ held = nil
1018
1110
  begin
1019
1111
  past = nil
1020
- _, drafts = update_state(run.job) do |previous|
1112
+ _, held = update_state(run.job) do |previous|
1021
1113
  past ||= history(run)
1022
- settled = Evaluate.apply_silence(previous, Evaluate.on_run_finish(definition, run, previous, past, at), at)
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
- dispatch(drafts, definition, at)
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 |run|
1059
- declared = @registry.synchronize { @definitions[run.job] }
1060
- definition = declared ? Serialize.to_stored(declared) : @store.get_job(run.job)&.definition
1061
- next if definition.nil? || !Evaluate.stuck?(definition, run, at)
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 #{run.job}")
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
- @store.list_jobs.each do |stored|
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, drafts = update_state(stored.name) do |previous|
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
- [settled.state, settled.alerts]
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(drafts, stored.definition, at))
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
- # Compose, triage and send each draft. The state was saved before this
1155
- # (update_state), so a slow channel holds up nothing else; afterwards only
1156
- # the delivery fields are written back, onto a fresh read of the state.
1157
- def dispatch(drafts, definition, at)
1158
- return [] if drafts.empty?
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
- drafts.each do |draft|
1164
- alert = Format.compose_alert(draft, definition, at)
1165
- if @defer_delivery
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(definition.name, delivered, failed, [], at)
1174
- composed
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. A failed alert replaces its stored copy, so a triage made on
1211
- # this attempt is kept. last_alert_at moves only on a delivery.
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
- state = Evaluate.normalize_state(previous, name)
1215
- done = (delivered + dropped).map { |a| alert_key(a) }.to_set
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
- rescue StandardError, ScriptError => e
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
- next outcomes[i] if thread.join([deadline - AbortSignal.monotonic, 0].max)
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
- rescue StandardError, ScriptError => e
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
- raise outcome[1] if outcome[0] == :error
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(e, "triage for #{alert.job}")
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