cronwatch 0.9.0 → 0.11.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.
Files changed (50) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +20 -11
  3. data/lib/cronwatch/alerts/datadog.rb +1 -0
  4. data/lib/cronwatch/alerts/discord.rb +34 -2
  5. data/lib/cronwatch/alerts/email.rb +2 -0
  6. data/lib/cronwatch/alerts/honeybadger.rb +1 -0
  7. data/lib/cronwatch/alerts/postmark.rb +1 -0
  8. data/lib/cronwatch/alerts/provider.rb +2 -0
  9. data/lib/cronwatch/alerts/resend.rb +1 -0
  10. data/lib/cronwatch/alerts/rollbar.rb +1 -0
  11. data/lib/cronwatch/alerts/sentry.rb +1 -0
  12. data/lib/cronwatch/alerts/sigv4.rb +2 -0
  13. data/lib/cronwatch/alerts/slack.rb +1 -0
  14. data/lib/cronwatch/alerts/twilio.rb +1 -0
  15. data/lib/cronwatch/alerts/webhook.rb +18 -5
  16. data/lib/cronwatch/client.rb +275 -110
  17. data/lib/cronwatch/cron_pattern.rb +17 -8
  18. data/lib/cronwatch/deprecation.rb +18 -0
  19. data/lib/cronwatch/duration.rb +2 -0
  20. data/lib/cronwatch/environment.rb +109 -7
  21. data/lib/cronwatch/evaluate.rb +145 -10
  22. data/lib/cronwatch/flight.rb +6 -0
  23. data/lib/cronwatch/format.rb +1 -0
  24. data/lib/cronwatch/http.rb +21 -5
  25. data/lib/cronwatch/job.rb +7 -2
  26. data/lib/cronwatch/js.rb +74 -1
  27. data/lib/cronwatch/monitored.rb +6 -1
  28. data/lib/cronwatch/output.rb +28 -1
  29. data/lib/cronwatch/pg_cron.rb +29 -6
  30. data/lib/cronwatch/rails/active_job.rb +2 -2
  31. data/lib/cronwatch/schedule.rb +25 -1
  32. data/lib/cronwatch/scheduler.rb +79 -11
  33. data/lib/cronwatch/serialize.rb +1 -0
  34. data/lib/cronwatch/stats.rb +1 -0
  35. data/lib/cronwatch/stores/active_record.rb +37 -11
  36. data/lib/cronwatch/ticker.rb +14 -0
  37. data/lib/cronwatch/triage/anthropic.rb +2 -0
  38. data/lib/cronwatch/types.rb +199 -27
  39. data/lib/cronwatch/version.rb +1 -1
  40. data/lib/cronwatch/walker.rb +2 -0
  41. data/lib/cronwatch/web/app.rb +114 -37
  42. data/lib/cronwatch/web/html.rb +9 -5
  43. data/lib/cronwatch/web/origin.rb +2 -0
  44. data/lib/cronwatch/web/pwa.rb +2 -0
  45. data/lib/cronwatch/web/timeline.rb +2 -0
  46. data/lib/cronwatch/web.rb +1 -2
  47. data/lib/cronwatch/zone.rb +2 -0
  48. data/lib/cronwatch.rb +2 -0
  49. data/lib/generators/cronwatch/install/install_generator.rb +52 -8
  50. metadata +2 -1
@@ -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
@@ -42,8 +42,16 @@ module Cronwatch
42
42
  SILENCE_OPTIONS = %i[for].freeze
43
43
  # Run ids that start with this belong to the pg_cron source (Sources::PgCron).
44
44
  RESERVED_RUN_ID_PREFIX = "pgcron:"
45
+ # The longest run id, in UTF-16 code units (JavaScript's string length):
46
+ # what start, resume and record_run take, and every store holds.
47
+ MAX_RUN_ID = 200
48
+ private_constant :CHANNEL_TIMEOUT_MS, :DEFAULT_OPTIONS, :FORK_LOCK, :HISTORY_MAX, :HISTORY_PAGE, :MAX_RUN_ID,
49
+ :PRUNE_INTERVAL_MS, :RESERVED_RUN_ID_PREFIX, :RETRY_BUDGET_MS, :SILENCE_OPTIONS, :STATE_ATTEMPTS,
50
+ :TRIAGE_TIMEOUT_MS
45
51
 
46
52
  # What execute returns: the recorded run, and the block's own outcome.
53
+ #
54
+ # @api private
47
55
  ExecuteResult = Struct.new(:run, :result, :error, :threw, keyword_init: true)
48
56
  # What a channel's call receives with each alert (the SDK's ChannelContext).
49
57
  # on_error(error) reports a problem that did not stop the alert going
@@ -73,7 +81,9 @@ module Cronwatch
73
81
  # alerts: where alerts go: objects with #call(alert) and #name. Defaults to the console.
74
82
  # triage: a callable taking a TriageContext and returning a short diagnosis, added to every alert but recoveries.
75
83
  # cron_secret: a second bearer Cronwatch::Web accepts for /api/check, for an outside cron. Defaults to
76
- # ENV["CRON_SECRET"]; an empty string counts as unset. Pass nil for none.
84
+ # ENV["CRON_SECRET"]; an empty string or one of only whitespace, given here or in the
85
+ # variable, counts as unset. Pass nil for none. Anything but a String or nil (false, a
86
+ # number, a Symbol) raises TypeError.
77
87
  # retention: how long finished runs are kept. Default "30d".
78
88
  # defaults: grace, timeout, timezone and failures_before_alert applied to every job unless it sets its own.
79
89
  # redact: applied to every run's output and error before it is stored, shown or sent to an alert
@@ -98,8 +108,11 @@ module Cronwatch
98
108
  raise ArgumentError, "a source must respond to sync(host)" unless source.respond_to?(:sync)
99
109
  end
100
110
  @triage = triage
101
- secret = cron_secret.equal?(UNSET) ? ENV.fetch("CRON_SECRET", nil) : cron_secret
102
- @cron_secret = secret.nil? || secret.to_s.empty? ? nil : secret.to_s
111
+ # A blank secret, given or read, counts as unset; one given that is not a String raises.
112
+ @cron_secret =
113
+ if cron_secret.equal?(UNSET) then Environment.secret("CRON_SECRET")
114
+ else Environment.secret_option(cron_secret, "cron_secret")
115
+ end
103
116
  @retention_ms = Duration.parse(retention || "30d", "retention")
104
117
  @defaults = (defaults || {}).transform_keys(&:to_sym)
105
118
  unknown = @defaults.keys - DEFAULT_OPTIONS
@@ -128,7 +141,7 @@ module Cronwatch
128
141
  @registry = Mutex.new
129
142
  @ready = false
130
143
  @last_prune_at = 0
131
- # Seconds before start()'s first check, and how long a channel or triage may take. Tests shorten them.
144
+ # Seconds before start_checking's first check, and how long a channel or triage may take. Tests shorten them.
132
145
  @first_tick_s = 1.0
133
146
  @channel_timeout_ms = CHANNEL_TIMEOUT_MS
134
147
  @triage_timeout_ms = TRIAGE_TIMEOUT_MS
@@ -158,13 +171,16 @@ module Cronwatch
158
171
  JobHandle.new(self, definition)
159
172
  end
160
173
 
161
- # With a block: run a job by name without keeping a handle, declaring it
162
- # on first use (or again, when options are given). Without a block: the
163
- # run with this id, as get_run.
174
+ # Run a job by name without keeping a handle, declaring it on first use
175
+ # (or again, when options are given).
176
+ #
177
+ # Without a block it reads the run with this id, as get_run does: that
178
+ # form is deprecated (use get_run), still works through 1.x and goes in 2.0.
164
179
  def run(name_or_id, **options, &block)
165
180
  unless block
166
181
  raise ArgumentError, "run(#{name_or_id.inspect}, ...) needs a block; without one, run(id) reads a run" if options.any?
167
182
 
183
+ Deprecation.warn("run(id) without a block", "get_run(id)")
168
184
  return get_run(name_or_id)
169
185
  end
170
186
 
@@ -198,7 +214,7 @@ module Cronwatch
198
214
  duration_ms: nil, error: nil, output: nil, metrics: {}, trigger: trigger)
199
215
  recorded = false
200
216
  begin
201
- sync(definition)
217
+ sync(definition, confirm: true)
202
218
  @store.insert_run(run.dup)
203
219
  recorded = true
204
220
  rescue StandardError => e
@@ -232,7 +248,7 @@ module Cronwatch
232
248
  run.duration_ms = Evaluate.run_duration(started_at, finished_at)
233
249
  run.metrics = recorder.metrics
234
250
  returned = result.is_a?(String) ? Output.utf8(result) : nil
235
- run.output = recorder.output || (returned && Output.cap(returned))
251
+ run.output = recorder.output || returned
236
252
 
237
253
  conclude(definition, run, result, error, threw, recorder.expect_text || returned, failure: failure)
238
254
  begin
@@ -316,9 +332,10 @@ module Cronwatch
316
332
  # alone and reported. A finished run is judged as if it had been wrapped
317
333
  # here (expect, failures, duration, budgets) and its output and error are
318
334
  # 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.
335
+ # judged only when it succeeded, as RunHandle#finish does. A metric that
336
+ # is not a finite number raises ArgumentError before anything is written,
337
+ # as JobContext#metric does. `evaluate: false` stores it without judging
338
+ # it, for history imported on first sight. Returns the alerts it sent.
322
339
  #
323
340
  # `run` is a Cronwatch::Run, or a hash of its fields (camelCase or snake_case keys).
324
341
  def record_run(run, evaluate: true)
@@ -326,9 +343,24 @@ module Cronwatch
326
343
  input = run.is_a?(Run) ? run : Run.from_h(run.is_a?(Hash) ? run.to_h { |k, v| [Naming.camel(k), v] } : run)
327
344
  declared = @registry.synchronize { @definitions[input.job] }
328
345
  raise ArgumentError, "record_run: job \"#{input.job}\" is not declared; call job first" unless declared
329
- if input.id.to_s.include?("\0")
346
+ # The longest id start takes, and every store holds.
347
+ unless input.id.is_a?(String) && !input.id.empty? && JS.length16(input.id) <= MAX_RUN_ID
348
+ got = input.id.is_a?(String) ? "#{JS.length16(input.id)} characters" : input.id.class.to_s
349
+ raise ArgumentError, "record_run: run ids must be 1 to #{MAX_RUN_ID} characters (got #{got}; job \"#{input.job}\")"
350
+ end
351
+ if input.id.include?("\0")
330
352
  raise ArgumentError, "record_run: run ids cannot contain a NUL character (job \"#{input.job}\")"
331
353
  end
354
+ # Refused as JobContext#metric refuses them: a store keeps NaN and Infinity as null.
355
+ unless input.metrics.nil? || input.metrics.is_a?(Hash)
356
+ raise ArgumentError, "record_run: metrics must be a Hash of numbers (job \"#{input.job}\", run \"#{input.id}\")"
357
+ end
358
+
359
+ (input.metrics || {}).each do |metric, value|
360
+ next if value.is_a?(Numeric) && value.real? && JS.finite?(value)
361
+
362
+ raise ArgumentError, "record_run: metric \"#{metric}\" must be a finite number (job \"#{input.job}\", run \"#{input.id}\")"
363
+ end
332
364
 
333
365
  sync(declared)
334
366
  run = input.dup
@@ -343,8 +375,8 @@ module Cronwatch
343
375
  run.error = unmet
344
376
  end
345
377
  end
346
- run.output = Output.strip_nul(@redact.call(Output.cap(run.output))) unless run.output.nil?
347
- run.error = Output.strip_nul(@redact.call(Output.cap(run.error))) unless run.error.nil?
378
+ run.output = Output.redact_and_cap(run.output, @redact) unless run.output.nil?
379
+ run.error = Output.redact_and_cap(run.error, @redact) unless run.error.nil?
348
380
  definition = Serialize.to_stored(declared)
349
381
 
350
382
  stored = @store.get_run(run.id)
@@ -377,7 +409,7 @@ module Cronwatch
377
409
  end
378
410
 
379
411
  # Look for missed and stuck runs across every job, send alerts, retry
380
- # alerts no channel accepted, and prune old runs. Call it from start(), a
412
+ # alerts no channel accepted, and prune old runs. Call it from start_checking, a
381
413
  # scheduled job (Cronwatch::CheckJob), or by hand. Concurrent calls share
382
414
  # one check.
383
415
  def check
@@ -414,16 +446,18 @@ module Cronwatch
414
446
  def jobs_with_runs(limit = 20)
415
447
  after_fork_check
416
448
  ensure_ready
417
- defined_jobs.each { |definition| sync(definition) }
449
+ jobs = stored_jobs
418
450
  at = now
419
451
  count = clamp_limit(limit, 20, 0)
420
- @store.list_jobs.map { |stored| snapshot(stored, at, count) }
452
+ jobs.map { |stored| snapshot(stored, at, count) }
421
453
  end
422
454
 
455
+ # A job's summary, or nil for one the store does not have. One declared
456
+ # here and forgotten elsewhere is written again, as stored_jobs does.
423
457
  def job_summary(name)
424
458
  ensure_ready
425
459
  definition = @registry.synchronize { @definitions[name] }
426
- sync(definition) if definition
460
+ sync(definition, confirm: true) if definition
427
461
  stored = @store.get_job(name)
428
462
  return nil unless stored
429
463
 
@@ -441,23 +475,30 @@ module Cronwatch
441
475
  @store.get_run(id)
442
476
  end
443
477
 
444
- # Stop alerts for a job for a while. State keeps updating underneath.
445
- # silence("nightly-report", for: "2h") # or silence("nightly-report", "2h")
478
+ # Stop alerts for a job for a while. State keeps updating underneath. The
479
+ # end is a whole millisecond, held at 2**53 - 1 (see Evaluate.silence_end).
480
+ # silence("nightly-report", for: "2h")
481
+ # The duration as a second argument, silence("nightly-report", "2h"), is
482
+ # deprecated: it still works through 1.x and goes in 2.0.
446
483
  def silence(name, duration = nil, **options)
447
484
  unknown = options.keys - SILENCE_OPTIONS
448
485
  raise ArgumentError, "silence takes for:, not #{unknown.map(&:inspect).join(", ")}" if unknown.any?
449
486
  raise ArgumentError, "silence takes a duration or for:, not both" if !duration.nil? && options.key?(:for)
450
487
 
488
+ Deprecation.warn("silence(name, duration)", "silence(name, for: duration)") unless duration.nil?
451
489
  duration = options[:for] if duration.nil?
452
490
  ms = Duration.parse(duration, "silence duration")
453
- patch_state(name) { |state| state.silenced_until = now + ms }
491
+ patch_state(name) { |state| state.silenced_until = Evaluate.silence_end(now, ms) }
454
492
  end
455
493
 
456
494
  def unsilence(name)
457
495
  patch_state(name) { |state| state.silenced_until = nil }
458
496
  end
459
497
 
460
- # Remove a job and its runs from the store. A job still declared in code comes back on its next run.
498
+ # Remove a job and its runs from the store. A job still declared in code
499
+ # comes back: here on its next run, and in any other process that
500
+ # declares it on its next run there, or at that process's next check or
501
+ # dashboard read.
461
502
  def forget(name)
462
503
  ensure_ready
463
504
  @registry.synchronize do
@@ -472,15 +513,18 @@ module Cronwatch
472
513
  # processes. Default every minute; the first check comes after a second.
473
514
  # Calling it again while it runs does nothing, and a different interval
474
515
  # is reported to on_error and ignored: stop first to change it. A forked
475
- # child (Puma, Unicorn, Sidekiq) has no thread, so call start there.
476
- def start(every = "1m")
516
+ # child (Puma, Unicorn, Sidekiq) has no thread, so call start_checking
517
+ # there. (A job's start opens a run; this starts the checks.)
518
+ def start_checking(every = "1m")
477
519
  after_fork_check
478
520
  ms = [5_000, Duration.parse(every, "check interval")].max
479
521
  @ticker_lock.synchronize do
522
+ # One whose thread ended (killed from outside) is replaced.
523
+ @ticker = nil if @ticker && !@ticker.alive?
480
524
  if @ticker
481
525
  unless @ticker_ms == ms
482
- report(ArgumentError.new("start(#{every.inspect}) ignored: already checking every #{Duration.format(@ticker_ms)}; " \
483
- "call stop first to change it"), "start")
526
+ report(ArgumentError.new("start_checking(#{every.inspect}) ignored: already checking every #{Duration.format(@ticker_ms)}; " \
527
+ "call stop first to change it"), "start_checking")
484
528
  end
485
529
  return nil
486
530
  end
@@ -488,31 +532,59 @@ module Cronwatch
488
532
  @ticker_ms = ms
489
533
  if @defer_delivery && !@warned_deferred_start
490
534
  @warned_deferred_start = true
491
- warn '[cronwatch] start() was called with deliver: "check", so these checks send no alerts. ' \
535
+ warn '[cronwatch] start_checking was called with deliver: "check", so these checks send no alerts. ' \
492
536
  'Another process must run checks with deliver: "now" (the default) to send them.'
493
537
  end
494
538
  @ticker = Ticker.new(ms / 1000.0, @first_tick_s) do
495
539
  check
496
- rescue StandardError => e
540
+ # Anything, a source's LoadError or a SystemStackError included, is
541
+ # reported and the next tick comes as usual: the thread must not end
542
+ # silently and leave missed and stuck unchecked in this process.
543
+ rescue Exception => e # rubocop:disable Lint/RescueException
497
544
  report(e, "check")
498
545
  end
499
546
  end
500
547
  nil
501
548
  end
502
549
 
550
+ # start_checking, under its former name. Deprecated, since a job's start
551
+ # opens a run: it still works through 1.x and goes in 2.0.
552
+ def start(every = "1m")
553
+ Deprecation.warn("client.start(every)", "client.start_checking(every)")
554
+ start_checking(every)
555
+ end
556
+
557
+ # The dashboard and JSON API for this client, as a Rack app to mount:
558
+ #
559
+ # mount Cronwatch.client.routes => "/cronwatch" # config/routes.rb
560
+ # run CW.routes # config.ru
561
+ #
562
+ # Options: token:, base_path: and origin: (see Cronwatch::Web). Needs the
563
+ # rack gem, which every Rails app has.
564
+ def routes(**options)
565
+ require_relative "web"
566
+ Web.build(self, **options)
567
+ end
568
+
569
+ # Stop the checks start_checking began.
503
570
  def stop
504
- after_fork_check
505
- ticker = @ticker_lock.synchronize do
506
- current = @ticker
507
- @ticker = nil
508
- current
509
- end
510
- ticker&.stop
571
+ stop_ticker
511
572
  nil
512
573
  end
513
574
 
575
+ # Stop the interval, wait for a check already under way (a failed one is
576
+ # reported by whoever started it), then close the store. Runs being
577
+ # recorded are not waited for.
514
578
  def close
515
- stop
579
+ stop_ticker&.join
580
+ flight = @check_lock.synchronize { @checking }
581
+ if flight && !flight.owner.equal?(Thread.current)
582
+ begin
583
+ flight.value
584
+ rescue StandardError
585
+ nil
586
+ end
587
+ end
516
588
  @store.close if @store.respond_to?(:close)
517
589
  nil
518
590
  end
@@ -534,6 +606,18 @@ module Cronwatch
534
606
 
535
607
  private
536
608
 
609
+ # Stops the interval thread and returns it, or nil when there was none.
610
+ def stop_ticker
611
+ after_fork_check
612
+ ticker = @ticker_lock.synchronize do
613
+ current = @ticker
614
+ @ticker = nil
615
+ current
616
+ end
617
+ ticker&.stop
618
+ ticker
619
+ end
620
+
537
621
  # Locks, the check in flight, the interval thread and the channel and
538
622
  # triage threads belong to one process. A forked child (Puma, Unicorn,
539
623
  # Sidekiq) starts with fresh ones, so start and check work there.
@@ -653,11 +737,22 @@ module Cronwatch
653
737
  # its own. The writes of one name take turns (the name's lock in
654
738
  # @syncing, held across the write), so one still under way cannot land
655
739
  # after a later one; and a name declared again while its write was under
656
- # way is still to be written.
657
- def sync(definition)
740
+ # way is still to be written. A name is marked as written only while
741
+ # that same declaration stands, so a forget that lands during the write
742
+ # (deleting the row after it) leaves the name to be written again, as
743
+ # does one forgotten before it.
744
+ #
745
+ # With `confirm`, as a run starts, a name already written is read back:
746
+ # another process may have forgotten the job since, and a job still
747
+ # declared here comes back on its next run.
748
+ def sync(definition, confirm: false)
658
749
  ensure_ready
659
750
  name = definition.name
660
- return if @registry.synchronize { @synced.include?(name) }
751
+ if @registry.synchronize { @synced.include?(name) }
752
+ return if !confirm || @store.get_job(name)
753
+
754
+ @registry.synchronize { @synced.delete(name) }
755
+ end
661
756
 
662
757
  after_fork_check
663
758
  turn = @registry.synchronize { @syncing[name] ||= Mutex.new }
@@ -666,8 +761,28 @@ module Cronwatch
666
761
  next if standing.nil?
667
762
 
668
763
  @store.upsert_job(Serialize.to_stored(standing), now)
669
- @registry.synchronize { @synced << name if @definitions.fetch(name, standing).equal?(standing) }
764
+ @registry.synchronize { @synced << name if @definitions[name].equal?(standing) }
765
+ end
766
+ end
767
+
768
+ # Every stored job, once each declaration has been written. A job
769
+ # declared here that the store no longer has was forgotten by another
770
+ # process after this one wrote it: it is written again, as its next run
771
+ # would, so it is checked and shown while any process still declares it.
772
+ def stored_jobs
773
+ defined_jobs.each { |definition| sync(definition) }
774
+ jobs = @store.list_jobs
775
+ listed = jobs.to_set(&:name)
776
+ missing = defined_jobs.reject { |definition| listed.include?(definition.name) }
777
+ return jobs if missing.empty?
778
+
779
+ missing.each do |definition|
780
+ name = definition.name
781
+ # Not one forgotten here meanwhile.
782
+ standing = @registry.synchronize { @definitions[name].equal?(definition) && @synced.delete(name) }
783
+ sync(definition) if standing
670
784
  end
785
+ @store.list_jobs
671
786
  end
672
787
 
673
788
  # Runs the block while holding the job's lock, so two runs (or a run and a
@@ -743,11 +858,6 @@ module Cronwatch
743
858
  end
744
859
  end
745
860
 
746
- # Identifies an alert across retries.
747
- def alert_key(alert)
748
- "#{alert.type}|#{alert.at}|#{alert.run&.id}"
749
- end
750
-
751
861
  # record_run for a run already stored.
752
862
  def record_over(definition, stored, run, evaluate)
753
863
  if stored.job != run.job
@@ -795,11 +905,12 @@ module Cronwatch
795
905
  end
796
906
 
797
907
  # Sets a finished run's status and error from how it ended, then redacts
798
- # its output and error. Shared by execute and RunHandle#finish.
908
+ # its output and error and caps them, in that order. Shared by execute
909
+ # and RunHandle#finish.
799
910
  def conclude(definition, run, result, error, threw, expect_text, failure: nil)
800
911
  if threw
801
912
  run.status = :failed
802
- run.error = Output.error_message(error)
913
+ run.error = Output.describe_error(error)
803
914
  elsif (problem = failure&.call(result))
804
915
  run.status = :failed
805
916
  run.error = Output.utf8(problem.to_s)
@@ -813,9 +924,10 @@ module Cronwatch
813
924
  end
814
925
  end
815
926
  # Redacted after the expect check, so a rule can still match what was
816
- # logged. NULs go last, so not even a custom redact can store one.
817
- run.output = Output.strip_nul(@redact.call(run.output)) unless run.output.nil?
818
- run.error = Output.strip_nul(@redact.call(run.error)) unless run.error.nil?
927
+ # logged, and before the cap, so the cut cannot keep half a secret. NULs
928
+ # go last, so not even a custom redact can store one.
929
+ run.output = Output.redact_and_cap(run.output, @redact) unless run.output.nil?
930
+ run.error = Output.redact_and_cap(run.error, @redact) unless run.error.nil?
819
931
  end
820
932
 
821
933
  # Writes a finished run and evaluates it. `recorded` says whether its
@@ -868,7 +980,7 @@ module Cronwatch
868
980
  duration_ms: nil, error: nil, output: nil, metrics: {}, trigger: trigger)
869
981
  recorded = false
870
982
  begin
871
- sync(definition)
983
+ sync(definition, confirm: true)
872
984
  @store.insert_run(run.dup)
873
985
  recorded = true
874
986
  rescue StandardError => e
@@ -955,13 +1067,14 @@ module Cronwatch
955
1067
  failed, result, error = RunHandle.read_outcome(outcome)
956
1068
  finished_at = now
957
1069
  returned = result.is_a?(String) ? Output.utf8(result) : nil
958
- added = recorder.output || (returned && Output.cap(returned))
1070
+ added = recorder.output || returned
959
1071
  run = from.dup
960
1072
  run.status = :running
961
1073
  run.finished_at = finished_at
962
1074
  run.duration_ms = Evaluate.run_duration(from.started_at, finished_at)
963
1075
  run.error = nil
964
- run.output = join_output(from.output, added)
1076
+ # Capped by conclude, after it is redacted.
1077
+ run.output = join_lines(from.output, added)
965
1078
  run.metrics = (from.metrics || {}).merge(recorder.metrics)
966
1079
  expect_text = join_lines(head, join_lines(from.output, recorder.expect_text || returned))
967
1080
  conclude(definition, run, result, error, failed, expect_text)
@@ -998,7 +1111,7 @@ module Cronwatch
998
1111
  end
999
1112
 
1000
1113
  updated = stored.dup
1001
- updated.output = join_output(stored.output, Output.strip_nul(@redact.call(lines))) unless lines.nil?
1114
+ updated.output = join_output(stored.output, Output.redact_and_cap(lines, @redact)) unless lines.nil?
1002
1115
  updated.metrics = (stored.metrics || {}).merge(metrics)
1003
1116
  write_run_if(updated, [:running])
1004
1117
  rescue StandardError => e
@@ -1008,9 +1121,9 @@ module Cronwatch
1008
1121
 
1009
1122
  # Raises for a run id no store could hold, or one reserved for the pg_cron source.
1010
1123
  def check_run_id(job, id, method)
1011
- unless id.is_a?(String) && !id.empty? && JS.length16(id) <= 200
1124
+ unless id.is_a?(String) && !id.empty? && JS.length16(id) <= MAX_RUN_ID
1012
1125
  got = id.is_a?(String) ? "#{JS.length16(id)} characters" : id.class.to_s
1013
- raise ArgumentError, "job \"#{job}\": #{method}() needs a run id of 1 to 200 characters (got #{got})"
1126
+ raise ArgumentError, "job \"#{job}\": #{method}() needs a run id of 1 to #{MAX_RUN_ID} characters (got #{got})"
1014
1127
  end
1015
1128
  # Postgres refuses NUL in text, so no store could hold such an id.
1016
1129
  raise ArgumentError, "job \"#{job}\": #{method}() cannot take a run id containing a NUL character" if id.include?("\0")
@@ -1035,21 +1148,47 @@ module Cronwatch
1035
1148
  end
1036
1149
 
1037
1150
  # Evaluate a finished run (ok, failed, or timed out by a check), already
1038
- # written, against the job's state and send what that produces. Never raises.
1151
+ # written, against the job's state and send what that produces. The
1152
+ # alerts are written with that state (see outbox). Never raises.
1039
1153
  def finish_run(definition, run, at)
1040
- drafts = nil
1154
+ held = nil
1041
1155
  begin
1042
1156
  past = nil
1043
- _, drafts = update_state(run.job) do |previous|
1157
+ _, held = update_state(run.job) do |previous|
1044
1158
  past ||= history(run)
1045
- settled = Evaluate.apply_silence(previous, Evaluate.on_run_finish(definition, run, previous, past, at), at)
1046
- [settled.state, settled.alerts]
1159
+ outbox(Evaluate.apply_silence(previous, Evaluate.on_run_finish(definition, run, previous, past, at), at), definition, at)
1047
1160
  end
1048
1161
  rescue StandardError => e
1049
1162
  report(e, "evaluating #{run.job}")
1050
1163
  return []
1051
1164
  end
1052
- dispatch(drafts, definition, at)
1165
+ report_dropped(run.job, held.dropped)
1166
+ dispatch(run.job, held.alerts, at)
1167
+ end
1168
+
1169
+ # Alerts written with the state that opened their conditions, and how
1170
+ # many older ones the queue let go.
1171
+ #
1172
+ # @api private
1173
+ Outbox = Struct.new(:alerts, :dropped)
1174
+
1175
+ # An evaluation as it is written: its drafts composed into alerts and held
1176
+ # in the same state (Evaluate.hold_alerts), so the write that opens a
1177
+ # condition also keeps its alerts, and a process that stops before
1178
+ # sending them does not lose them. Called inside update_state, so it only
1179
+ # computes. Returns `[state, Outbox]`.
1180
+ def outbox(settled, definition, at)
1181
+ alerts = settled.alerts.map { |draft| Format.compose_alert(draft, definition, at) }
1182
+ held = Evaluate.hold_alerts(settled.state, alerts, now + Evaluate::SEND_LEASE_MS, @defer_delivery)
1183
+ [held.state, Outbox.new(alerts, held.dropped)]
1184
+ end
1185
+
1186
+ # Reports alerts let go because a job's queue was full.
1187
+ def report_dropped(name, dropped)
1188
+ return unless dropped.positive?
1189
+
1190
+ report(RuntimeError.new("#{dropped} undelivered alert#{dropped == 1 ? "" : "s"} for #{name} dropped: " \
1191
+ "only the newest #{Evaluate::MAX_UNDELIVERED} are kept for retry"), "alert queue for #{name}")
1053
1192
  end
1054
1193
 
1055
1194
  # The runs before `run`, newest first, with up to BASELINE_WINDOW
@@ -1078,10 +1217,18 @@ module Cronwatch
1078
1217
 
1079
1218
  # Runs that never reported back. One that cannot be judged (its job's
1080
1219
  # stored timeout no longer parses, say) is reported and skipped.
1081
- @store.running_runs.each do |run|
1082
- declared = @registry.synchronize { @definitions[run.job] }
1083
- definition = declared ? Serialize.to_stored(declared) : @store.get_job(run.job)&.definition
1084
- next if definition.nil? || !Evaluate.stuck?(definition, run, at)
1220
+ @store.running_runs.each do |listed|
1221
+ declared = @registry.synchronize { @definitions[listed.job] }
1222
+ definition = declared ? Serialize.to_stored(declared) : @store.get_job(listed.job)&.definition
1223
+ next if definition.nil?
1224
+
1225
+ evaluable(listed.job, definition)
1226
+ next unless Evaluate.stuck?(definition, listed, at)
1227
+
1228
+ # Read again just before the write: lines and metrics flushed since the
1229
+ # list was read (while earlier stuck runs were sent, say) are kept.
1230
+ run = @store.get_run(listed.id)
1231
+ next if run.nil? || run.status != :running || run.job != listed.job
1085
1232
 
1086
1233
  timeout = Evaluate.timeout_ms(definition)
1087
1234
  run.status = :timeout
@@ -1093,7 +1240,7 @@ module Cronwatch
1093
1240
 
1094
1241
  alerts.concat(finish_run(definition, run, at))
1095
1242
  rescue StandardError => e
1096
- report(e, "checking #{run.job}")
1243
+ report(e, "checking #{listed.job}")
1097
1244
  end
1098
1245
 
1099
1246
  # Each job on its own: one that cannot be evaluated (a stored schedule
@@ -1102,17 +1249,22 @@ module Cronwatch
1102
1249
  # the others.
1103
1250
  jobs = []
1104
1251
  retries = RetryBudget.new(0)
1105
- @store.list_jobs.each do |stored|
1252
+ stored_jobs.each do |stored|
1253
+ evaluable(stored.name, stored.definition)
1106
1254
  recent = @store.list_runs(stored.name, Evaluate::BASELINE_WINDOW)
1107
1255
  next_expected_at = nil
1108
- state, drafts = update_state(stored.name) do |previous|
1256
+ state, held = update_state(stored.name) do |previous|
1109
1257
  evaluation = Evaluate.on_check(stored.definition, stored, recent.first, previous, at)
1110
1258
  next_expected_at = evaluation.next_expected_at
1111
1259
  settled = Evaluate.apply_silence(previous, evaluation, at)
1112
- [settled.state, settled.alerts]
1260
+ # Alerts a process stopped sending part way go back to the retry queue.
1261
+ released = Evaluate.release_sending(settled.state, now)
1262
+ following, out = outbox(Evaluate::Evaluation.new(state: released.state, alerts: settled.alerts), stored.definition, at)
1263
+ [following, Outbox.new(out.alerts, released.dropped + out.dropped)]
1113
1264
  end
1265
+ report_dropped(stored.name, held.dropped)
1114
1266
  alerts.concat(retry_undelivered(stored.name, state, at, retries))
1115
- alerts.concat(dispatch(drafts, stored.definition, at))
1267
+ alerts.concat(dispatch(stored.name, held.alerts, at))
1116
1268
  jobs << Evaluate.summarize(stored, recent, state, next_expected_at, at)
1117
1269
  rescue StandardError => e
1118
1270
  report(e, "checking #{stored.name}")
@@ -1139,6 +1291,7 @@ module Cronwatch
1139
1291
  recent = []
1140
1292
  begin
1141
1293
  recent = @store.list_runs(stored.name, [count, Evaluate::BASELINE_WINDOW].max)
1294
+ evaluable(stored.name, stored.definition)
1142
1295
  state = read_state(stored.name)
1143
1296
  next_expected_at = Evaluate.on_check(stored.definition, stored, recent.first, state, at).next_expected_at
1144
1297
  JobWithRuns.new(job: Evaluate.summarize(stored, recent, state, next_expected_at, at), runs: recent.first(count))
@@ -1148,6 +1301,13 @@ module Cronwatch
1148
1301
  end
1149
1302
  end
1150
1303
 
1304
+ # Raises for a stored definition that was not a JSON object (see
1305
+ # JobDefinition.from_h), so that one job is reported and shown as
1306
+ # failing, and the others are checked as usual.
1307
+ def evaluable(name, definition)
1308
+ raise ArgumentError, "job \"#{name}\": its stored definition is not a JSON object" if definition.unreadable?
1309
+ end
1310
+
1151
1311
  # The summary of a job whose evaluation failed, from whatever can still be read.
1152
1312
  def unevaluable(stored, at)
1153
1313
  recent = begin
@@ -1174,30 +1334,30 @@ module Cronwatch
1174
1334
  state
1175
1335
  end
1176
1336
 
1177
- # Compose, triage and send each draft. The state was saved before this
1178
- # (update_state), so a slow channel holds up nothing else; afterwards only
1179
- # the delivery fields are written back, onto a fresh read of the state.
1180
- def dispatch(drafts, definition, at)
1181
- return [] if drafts.empty?
1337
+ # Triage and send each alert the outbox holds (see outbox). The state,
1338
+ # with the alerts in it, was saved before this, so a slow channel holds up
1339
+ # nothing else; afterwards only the delivery fields are written back,
1340
+ # onto a fresh read of the state, and the alerts leave `sending`. Triage
1341
+ # is made here, never stored with the held alert: the write that opens a
1342
+ # condition cannot wait for it, and a retry triages an alert that has
1343
+ # none. With deliver: :check the alerts were queued for a check elsewhere
1344
+ # instead.
1345
+ def dispatch(name, alerts, at)
1346
+ return alerts if alerts.empty? || @defer_delivery
1182
1347
 
1183
- composed = []
1184
1348
  delivered = []
1185
1349
  failed = []
1186
- drafts.each do |draft|
1187
- alert = Format.compose_alert(draft, definition, at)
1188
- if @defer_delivery
1189
- failed << alert
1190
- else
1191
- add_triage(alert, @triage_timeout_ms) if @triage && alert.type != :recovered
1192
- (deliver(alert) ? delivered : failed) << alert
1193
- end
1194
- composed << alert
1350
+ alerts.each do |alert|
1351
+ add_triage(alert, @triage_timeout_ms) if @triage && alert.type != :recovered
1352
+ (deliver(alert) ? delivered : failed) << alert
1195
1353
  end
1196
- record_delivery(definition.name, delivered, failed, [], at)
1197
- composed
1354
+ record_delivery(name, delivered, failed, [], at)
1355
+ alerts
1198
1356
  end
1199
1357
 
1200
1358
  # The wall-clock milliseconds one check has spent retrying, across its jobs.
1359
+ #
1360
+ # @api private
1201
1361
  RetryBudget = Struct.new(:spent_ms)
1202
1362
 
1203
1363
  # Send the alerts that no channel accepted last time, once each, oldest
@@ -1230,24 +1390,16 @@ module Cronwatch
1230
1390
  end
1231
1391
 
1232
1392
  # Mark delivered alerts done, drop stale ones, and keep failed ones for the
1233
- # next check. A failed alert replaces its stored copy, so a triage made on
1234
- # this attempt is kept. last_alert_at moves only on a delivery.
1393
+ # next check, taking them all out of `sending` (Evaluate.record_sent). A
1394
+ # failed alert replaces its stored copy, so a triage made on this attempt
1395
+ # is kept. last_alert_at moves only on a delivery. When this write fails,
1396
+ # alerts still in `sending` are retried once their lease runs out.
1235
1397
  def record_delivery(name, delivered, failed, dropped, at)
1236
1398
  _, trimmed = update_state(name) do |previous|
1237
- state = Evaluate.normalize_state(previous, name)
1238
- done = (delivered + dropped).map { |a| alert_key(a) }.to_set
1239
- retried = failed.to_h { |a| [alert_key(a), a] }
1240
- kept = state.undelivered.reject { |a| done.include?(alert_key(a)) }.map { |a| retried.fetch(alert_key(a), a) }
1241
- known = kept.map { |a| alert_key(a) }.to_set
1242
- kept.concat(failed.reject { |a| known.include?(alert_key(a)) })
1243
- state.undelivered = kept.last(MAX_UNDELIVERED)
1244
- state.last_alert_at = at if delivered.any?
1245
- [state, [0, kept.length - MAX_UNDELIVERED].max]
1246
- end
1247
- if trimmed.positive?
1248
- report(RuntimeError.new("#{trimmed} undelivered alert#{trimmed == 1 ? "" : "s"} for #{name} dropped: " \
1249
- "only the newest #{MAX_UNDELIVERED} are kept for retry"), "alert queue for #{name}")
1399
+ held = Evaluate.record_sent(Evaluate.normalize_state(previous, name), delivered, failed, dropped, at)
1400
+ [held.state, held.dropped]
1250
1401
  end
1402
+ report_dropped(name, trimmed)
1251
1403
  rescue StandardError => e
1252
1404
  report(e, "recording alert delivery for #{name}")
1253
1405
  end
@@ -1272,14 +1424,20 @@ module Cronwatch
1272
1424
  Thread.current.report_on_exception = false
1273
1425
  send_to(channel, alert)
1274
1426
  outcomes[i] = true
1275
- rescue StandardError, ScriptError => e
1427
+ # Anything the channel raised, SystemStackError and exit included, is
1428
+ # a failed send: raised again through join, it would replace the
1429
+ # job's own error and lose the alert.
1430
+ rescue Exception => e # rubocop:disable Lint/RescueException
1276
1431
  outcomes[i] = e
1277
1432
  end
1278
1433
  end
1279
1434
  deadline = AbortSignal.monotonic + (@channel_timeout_ms / 1000.0)
1280
1435
  results = threads.each_with_index.map do |thread, i|
1281
1436
  next outcomes[i] if thread.nil?
1282
- next outcomes[i] if thread.join([deadline - AbortSignal.monotonic, 0].max)
1437
+ if thread.join([deadline - AbortSignal.monotonic, 0].max)
1438
+ # A thread killed from outside leaves no outcome: not sent.
1439
+ next outcomes[i] || RuntimeError.new("the channel's thread ended without sending")
1440
+ end
1283
1441
 
1284
1442
  @sending_lock.synchronize { @abandoned[i] = thread }
1285
1443
  TimeoutError.new("timed out after #{@channel_timeout_ms}ms")
@@ -1334,21 +1492,28 @@ module Cronwatch
1334
1492
  thread = Thread.new do
1335
1493
  Thread.current.report_on_exception = false
1336
1494
  outcome = [:ok, @triage.call(context)]
1337
- rescue StandardError, ScriptError => e
1495
+ # Anything triage raised counts as no diagnosis, as a channel's does.
1496
+ rescue Exception => e # rubocop:disable Lint/RescueException
1338
1497
  outcome = [:error, e]
1339
1498
  end
1340
1499
  unless thread.join(timeout_ms / 1000.0)
1341
1500
  @sending_lock.synchronize { @abandoned[:triage] = thread }
1342
1501
  raise TimeoutError, "timed out after #{timeout_ms}ms"
1343
1502
  end
1344
- raise outcome[1] if outcome[0] == :error
1503
+ outcome ||= [:error, RuntimeError.new("the triage thread ended without an answer")]
1504
+ return no_triage(alert, signal, outcome[1]) if outcome[0] == :error
1345
1505
 
1346
1506
  diagnosis = outcome[1]
1347
1507
  alert.triage_result = diagnosis.is_a?(String) && !diagnosis.empty? ? Output.utf8(diagnosis) : nil
1348
1508
  rescue StandardError => e
1509
+ no_triage(alert, signal, e)
1510
+ end
1511
+
1512
+ # A triage that gave nothing: the alert goes without one, and why is reported.
1513
+ def no_triage(alert, signal, error)
1349
1514
  signal&.abort!
1350
1515
  alert.triage_result = nil
1351
- report(e, "triage for #{alert.job}")
1516
+ report(error, "triage for #{alert.job}")
1352
1517
  end
1353
1518
 
1354
1519
  # A whole number in range, or the fallback for anything that is not a number.