cronwatch 0.9.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6df945edfb266ca737de6343276860554a4d3d740331091e1167d00fe93888a4
4
- data.tar.gz: ec33c3140fb5f453de2cd0263b74a894b590408394f9f1aade76f1be2485e190
3
+ metadata.gz: cb5e3c0550792cfd9ba9d34f828cf099051f30b5b52cfd780373efe581e665e2
4
+ data.tar.gz: ba21834f9a3d3b8b641e26fa23990f34f12f2190b7ad5bbf7d9355b0bdc0685f
5
5
  SHA512:
6
- metadata.gz: ba15e440f49e390715b9668a24c0ee2b1c0cf35dac907a461555d18181fc95572b0ba3fee50b00237893733ca5b864b9f35b6a4e30b0f0bee64e6eefa1f3c5b0
7
- data.tar.gz: 71e579e3ebba4a279cfef5de79d611157dcc2fc8ad176fac77cc737e7e0750c9ecd3cfefed383b2ac51f2f7aef5a5a24a24a472b6b739e90c1bd007a5814f0f5
6
+ metadata.gz: bf853e3e1f3ac6db1f74178033a1bd92a0863a182285bfe944e872a8463f9a261319b8c5c27d5c2a2acd31d54a4f328a22b6a80d1d722170414aef18785e2dc4
7
+ data.tar.gz: f23c2439e7b9d0fb9f9f818cc49313c15d0e35e42dcffb74aaf246a327d4318b8951293060e269cf1e01224f5986ac25090ce9ef92c4e688f0d4e7feb8614976
data/README.md CHANGED
@@ -74,7 +74,7 @@ bin/rails generate cronwatch:install # a migration and config/initializers/cr
74
74
  bin/rails db:migrate
75
75
  ```
76
76
 
77
- The generator takes `--prefix` (table prefix, default `cronwatch_`) and `--database` (the database whose migrations directory gets the migration). The initializer it writes sets the ActiveRecord store, which Rails needs so web, worker and check processes see the same runs:
77
+ The generator takes `--prefix` (table prefix, default `cronwatch_`) and `--database` (the database, from `config/database.yml`, whose migrations directory gets the migration; it also writes `app/models/cronwatch_record.rb`, an abstract class that `connects_to` that database, and names it as the store's `connection_class`, so the store reads the tables where the migration made them). The initializer it writes sets the ActiveRecord store, which Rails needs so web, worker and check processes see the same runs:
78
78
 
79
79
  ```ruby
80
80
  # config/initializers/cronwatch.rb
@@ -124,7 +124,7 @@ The same `cronwatch` as for ActiveJob, for classes that include `Sidekiq::Job` (
124
124
  cronwatch schedule: :from_scheduler, grace: "15m" # ActiveJob or Sidekiq
125
125
  ```
126
126
 
127
- reads the class's one entry in Solid Queue's `config/recurring.yml` (the section for `Rails.env`) or sidekiq-cron's `config/schedule.yml`, so the schedule is written once. Fugit phrases such as `every day at 3am` or `every hour at minute 12` become the cron expression Fugit makes of them (`0 3 * * *`, `12 * * * *`), in the zone the scheduler reads them in. Each conversion is checked against Fugit's own next run times; anything CronWatch would not expect at exactly those times is refused at boot rather than approximated: every other week (`%`), days counted back from the month's end other than the last, random times (`~`), offsets instead of IANA zones, and a time that daylight saving skips (Fugit skips that run; CronWatch would report it missed). A class with no entry, or with two, also stops the boot.
127
+ reads the class's one entry in Solid Queue's `config/recurring.yml` (the section for `Rails.env`) or sidekiq-cron's `config/schedule.yml`, so the schedule is written once. Fugit phrases such as `every day at 3am` or `every hour at minute 12` become the cron expression Fugit makes of them (`0 3 * * *`, `12 * * * *`), in the zone the scheduler reads them in. Each conversion is checked against Fugit's own next run times; anything CronWatch would not expect at exactly those times is refused at boot rather than approximated: every other week (`%`), days counted back from the month's end other than the last, random times (`~`), offsets instead of IANA zones, and a time that daylight saving skips (Fugit skips that run; CronWatch would report it missed). A class with no entry, or with two, also stops the boot; one scheduled only in another environment's section (Rails 8's `recurring.yml` has only `production:`) is declared without a schedule, and a process started with `SOLID_QUEUE_SKIP_RECURRING` still reads the entry.
128
128
 
129
129
  `Cronwatch.declare_from_scheduler!(grace: "10m")` in the initializer watches every enabled entry once the app has booted: classes that call `cronwatch` declare themselves, other classes are named as `cronwatch` would name them and their runs are recorded, and Solid Queue `command:` tasks are named after their key. `except:` leaves keys out. `Cronwatch::Scheduler.sources` sets where to read, when the defaults (Solid Queue's file when Solid Queue is loaded, sidekiq-cron's when sidekiq-cron is) are not right.
130
130
 
@@ -168,7 +168,7 @@ Rails.application.routes.draw do
168
168
  end
169
169
  ```
170
170
 
171
- Set `CRONWATCH_TOKEN` to a long random string and open `/cronwatch?token=<it>` once; the browser keeps a cookie. Without a token, while the environment (`Rails.env`, else `RAILS_ENV` or `RACK_ENV`) is `development` or `test`, it makes a token of its own and prints a sign-in link to standard output on its first request (open it once; unless `origin:` is set or the request's host is loopback, the link leaves the host out, since a client chooses it); anywhere else it answers 503. To rely on the app's own sign in, mount it behind that (Devise's `authenticate` block, or a routing constraint) and pass `token: nil`. The URLs, JSON shapes, headers and CSRF rules are the SDK's, so the MCP server reads it unchanged. The request's origin, which the CSRF check compares against, is what Rack reports and so already follows `X-Forwarded-Host` and `X-Forwarded-Proto`; pass `origin: "https://app.example.com"` to pin it instead. `GET /cronwatch/api/check` with a bearer (the token or `CRON_SECRET`) runs the check, for an outside cron.
171
+ Set `CRONWATCH_TOKEN` to a long random string and open `/cronwatch?token=<it>` once; the browser keeps a cookie. Without a token, while the environment (`Rails.env`, else `RAILS_ENV` or `RACK_ENV`) is `development` or `test`, it makes a token of its own and prints a sign-in link to standard output on its first request (open it once; unless `origin:` is set or the request's host is loopback, the link leaves the host out, since a client chooses it); anywhere else it answers 503. Puma, Unicorn, Thin and rackup set `RACK_ENV` to `development` when nothing names an environment, production included, so under one of them `RACK_ENV` alone does not count: `Rails.env`, `RAILS_ENV` or `APP_ENV` must say `development` or `test` (or `RACK_ENV` say `test`). To rely on the app's own sign in, mount it behind that (Devise's `authenticate` block, or a routing constraint) and pass `token: nil`. The URLs, JSON shapes, headers and CSRF rules are the SDK's, so the MCP server reads it unchanged. The request's origin, which the CSRF check compares against, is what Rack reports and so already follows `X-Forwarded-Host` and `X-Forwarded-Proto`; pass `origin: "https://app.example.com"` to pin it instead. `GET /cronwatch/api/check` with a bearer (the token or `CRON_SECRET`) runs the check, for an outside cron.
172
172
 
173
173
  There is no `handler()` as in the TypeScript SDK: for a job triggered over HTTP, wrap the controller action's body in `CW.job(...).run` (declared once, at boot) and check the bearer in the controller.
174
174
 
@@ -9,6 +9,10 @@ module Cronwatch
9
9
  missed: 0xb7791f, failed: 0xc62828, stuck: 0xc62828, slow: 0xb7791f, over_budget: 0xb7791f, recovered: 0x1f8a4c,
10
10
  }.freeze
11
11
 
12
+ # The longest embed description Discord takes. The title (under 256)
13
+ # and it stay well inside the embed's 6000.
14
+ DESCRIPTION_MAX = 4096
15
+
12
16
  attr_reader :name
13
17
 
14
18
  def initialize(webhook_url:, link: nil, http: HTTP.default)
@@ -24,8 +28,7 @@ module Cronwatch
24
28
  url = @link&.call(alert)
25
29
  embed = { "title" => alert.title }
26
30
  embed["url"] = url if url && !url.empty?
27
- triage = alert.triage && !alert.triage.empty? ? "\n**Triage:** #{Discord.escape_markdown(JS.head16(alert.triage, 1000))}" : ""
28
- embed["description"] = "```\n#{Discord.code_block_safe(JS.head16(alert.message, 3800))}\n```#{triage}"
31
+ embed["description"] = Discord.embed_description(alert)
29
32
  embed["color"] = COLOR[alert.type]
30
33
  embed["timestamp"] = JS.iso(alert.at)
31
34
  payload = {
@@ -38,6 +41,16 @@ module Cronwatch
38
41
  raise "Discord webhook answered #{response.status}: #{JS.head16(response.body.to_s, 200)}" unless response.ok?
39
42
  end
40
43
 
44
+ # The message in a code block, then the triage. Each part has its own
45
+ # cap, and escaping can grow both, so the whole is held to
46
+ # DESCRIPTION_MAX (in UTF-16 units) by cutting the message's block,
47
+ # never the triage: Discord refuses a longer one on every retry.
48
+ def self.embed_description(alert)
49
+ triage = alert.triage && !alert.triage.empty? ? "\n**Triage:** #{escape_markdown(JS.head16(alert.triage, 1000))}" : ""
50
+ room = DESCRIPTION_MAX - "```\n".length - "\n```".length - JS.length16(triage)
51
+ "```\n#{JS.head16(code_block_safe(JS.head16(alert.message, 3800)), room)}\n```#{triage}"
52
+ end
53
+
41
54
  # Breaks up ``` so text inside a code block cannot close it.
42
55
  def self.code_block_safe(text)
43
56
  text.gsub("```", "`\u200b`\u200b`")
@@ -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)
@@ -329,6 +330,16 @@ module Cronwatch
329
330
  if input.id.to_s.include?("\0")
330
331
  raise ArgumentError, "record_run: run ids cannot contain a NUL character (job \"#{input.job}\")"
331
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
332
343
 
333
344
  sync(declared)
334
345
  run = input.dup
@@ -343,8 +354,8 @@ module Cronwatch
343
354
  run.error = unmet
344
355
  end
345
356
  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?
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?
348
359
  definition = Serialize.to_stored(declared)
349
360
 
350
361
  stored = @store.get_run(run.id)
@@ -414,16 +425,18 @@ module Cronwatch
414
425
  def jobs_with_runs(limit = 20)
415
426
  after_fork_check
416
427
  ensure_ready
417
- defined_jobs.each { |definition| sync(definition) }
428
+ jobs = stored_jobs
418
429
  at = now
419
430
  count = clamp_limit(limit, 20, 0)
420
- @store.list_jobs.map { |stored| snapshot(stored, at, count) }
431
+ jobs.map { |stored| snapshot(stored, at, count) }
421
432
  end
422
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.
423
436
  def job_summary(name)
424
437
  ensure_ready
425
438
  definition = @registry.synchronize { @definitions[name] }
426
- sync(definition) if definition
439
+ sync(definition, confirm: true) if definition
427
440
  stored = @store.get_job(name)
428
441
  return nil unless stored
429
442
 
@@ -441,7 +454,8 @@ module Cronwatch
441
454
  @store.get_run(id)
442
455
  end
443
456
 
444
- # 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).
445
459
  # silence("nightly-report", for: "2h") # or silence("nightly-report", "2h")
446
460
  def silence(name, duration = nil, **options)
447
461
  unknown = options.keys - SILENCE_OPTIONS
@@ -450,14 +464,17 @@ module Cronwatch
450
464
 
451
465
  duration = options[:for] if duration.nil?
452
466
  ms = Duration.parse(duration, "silence duration")
453
- patch_state(name) { |state| state.silenced_until = now + ms }
467
+ patch_state(name) { |state| state.silenced_until = Evaluate.silence_end(now, ms) }
454
468
  end
455
469
 
456
470
  def unsilence(name)
457
471
  patch_state(name) { |state| state.silenced_until = nil }
458
472
  end
459
473
 
460
- # 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.
461
478
  def forget(name)
462
479
  ensure_ready
463
480
  @registry.synchronize do
@@ -477,6 +494,8 @@ module Cronwatch
477
494
  after_fork_check
478
495
  ms = [5_000, Duration.parse(every, "check interval")].max
479
496
  @ticker_lock.synchronize do
497
+ # One whose thread ended (killed from outside) is replaced.
498
+ @ticker = nil if @ticker && !@ticker.alive?
480
499
  if @ticker
481
500
  unless @ticker_ms == ms
482
501
  report(ArgumentError.new("start(#{every.inspect}) ignored: already checking every #{Duration.format(@ticker_ms)}; " \
@@ -493,7 +512,10 @@ module Cronwatch
493
512
  end
494
513
  @ticker = Ticker.new(ms / 1000.0, @first_tick_s) do
495
514
  check
496
- 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
497
519
  report(e, "check")
498
520
  end
499
521
  end
@@ -501,18 +523,23 @@ module Cronwatch
501
523
  end
502
524
 
503
525
  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
526
+ stop_ticker
511
527
  nil
512
528
  end
513
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.
514
533
  def close
515
- 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
516
543
  @store.close if @store.respond_to?(:close)
517
544
  nil
518
545
  end
@@ -534,6 +561,18 @@ module Cronwatch
534
561
 
535
562
  private
536
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
+
537
576
  # Locks, the check in flight, the interval thread and the channel and
538
577
  # triage threads belong to one process. A forked child (Puma, Unicorn,
539
578
  # Sidekiq) starts with fresh ones, so start and check work there.
@@ -653,11 +692,22 @@ module Cronwatch
653
692
  # its own. The writes of one name take turns (the name's lock in
654
693
  # @syncing, held across the write), so one still under way cannot land
655
694
  # 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)
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)
658
704
  ensure_ready
659
705
  name = definition.name
660
- return if @registry.synchronize { @synced.include?(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
661
711
 
662
712
  after_fork_check
663
713
  turn = @registry.synchronize { @syncing[name] ||= Mutex.new }
@@ -666,8 +716,28 @@ module Cronwatch
666
716
  next if standing.nil?
667
717
 
668
718
  @store.upsert_job(Serialize.to_stored(standing), now)
669
- @registry.synchronize { @synced << name if @definitions.fetch(name, standing).equal?(standing) }
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
670
739
  end
740
+ @store.list_jobs
671
741
  end
672
742
 
673
743
  # Runs the block while holding the job's lock, so two runs (or a run and a
@@ -743,11 +813,6 @@ module Cronwatch
743
813
  end
744
814
  end
745
815
 
746
- # Identifies an alert across retries.
747
- def alert_key(alert)
748
- "#{alert.type}|#{alert.at}|#{alert.run&.id}"
749
- end
750
-
751
816
  # record_run for a run already stored.
752
817
  def record_over(definition, stored, run, evaluate)
753
818
  if stored.job != run.job
@@ -795,11 +860,12 @@ module Cronwatch
795
860
  end
796
861
 
797
862
  # 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.
863
+ # its output and error and caps them, in that order. Shared by execute
864
+ # and RunHandle#finish.
799
865
  def conclude(definition, run, result, error, threw, expect_text, failure: nil)
800
866
  if threw
801
867
  run.status = :failed
802
- run.error = Output.error_message(error)
868
+ run.error = Output.describe_error(error)
803
869
  elsif (problem = failure&.call(result))
804
870
  run.status = :failed
805
871
  run.error = Output.utf8(problem.to_s)
@@ -813,9 +879,10 @@ module Cronwatch
813
879
  end
814
880
  end
815
881
  # 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?
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?
819
886
  end
820
887
 
821
888
  # Writes a finished run and evaluates it. `recorded` says whether its
@@ -868,7 +935,7 @@ module Cronwatch
868
935
  duration_ms: nil, error: nil, output: nil, metrics: {}, trigger: trigger)
869
936
  recorded = false
870
937
  begin
871
- sync(definition)
938
+ sync(definition, confirm: true)
872
939
  @store.insert_run(run.dup)
873
940
  recorded = true
874
941
  rescue StandardError => e
@@ -955,13 +1022,14 @@ module Cronwatch
955
1022
  failed, result, error = RunHandle.read_outcome(outcome)
956
1023
  finished_at = now
957
1024
  returned = result.is_a?(String) ? Output.utf8(result) : nil
958
- added = recorder.output || (returned && Output.cap(returned))
1025
+ added = recorder.output || returned
959
1026
  run = from.dup
960
1027
  run.status = :running
961
1028
  run.finished_at = finished_at
962
1029
  run.duration_ms = Evaluate.run_duration(from.started_at, finished_at)
963
1030
  run.error = nil
964
- run.output = join_output(from.output, added)
1031
+ # Capped by conclude, after it is redacted.
1032
+ run.output = join_lines(from.output, added)
965
1033
  run.metrics = (from.metrics || {}).merge(recorder.metrics)
966
1034
  expect_text = join_lines(head, join_lines(from.output, recorder.expect_text || returned))
967
1035
  conclude(definition, run, result, error, failed, expect_text)
@@ -998,7 +1066,7 @@ module Cronwatch
998
1066
  end
999
1067
 
1000
1068
  updated = stored.dup
1001
- 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?
1002
1070
  updated.metrics = (stored.metrics || {}).merge(metrics)
1003
1071
  write_run_if(updated, [:running])
1004
1072
  rescue StandardError => e
@@ -1035,21 +1103,45 @@ module Cronwatch
1035
1103
  end
1036
1104
 
1037
1105
  # 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.
1106
+ # written, against the job's state and send what that produces. The
1107
+ # alerts are written with that state (see outbox). Never raises.
1039
1108
  def finish_run(definition, run, at)
1040
- drafts = nil
1109
+ held = nil
1041
1110
  begin
1042
1111
  past = nil
1043
- _, drafts = update_state(run.job) do |previous|
1112
+ _, held = update_state(run.job) do |previous|
1044
1113
  past ||= history(run)
1045
- settled = Evaluate.apply_silence(previous, Evaluate.on_run_finish(definition, run, previous, past, at), at)
1046
- [settled.state, settled.alerts]
1114
+ outbox(Evaluate.apply_silence(previous, Evaluate.on_run_finish(definition, run, previous, past, at), at), definition, at)
1047
1115
  end
1048
1116
  rescue StandardError => e
1049
1117
  report(e, "evaluating #{run.job}")
1050
1118
  return []
1051
1119
  end
1052
- 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}")
1053
1145
  end
1054
1146
 
1055
1147
  # The runs before `run`, newest first, with up to BASELINE_WINDOW
@@ -1078,10 +1170,18 @@ module Cronwatch
1078
1170
 
1079
1171
  # Runs that never reported back. One that cannot be judged (its job's
1080
1172
  # 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)
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
1085
1185
 
1086
1186
  timeout = Evaluate.timeout_ms(definition)
1087
1187
  run.status = :timeout
@@ -1093,7 +1193,7 @@ module Cronwatch
1093
1193
 
1094
1194
  alerts.concat(finish_run(definition, run, at))
1095
1195
  rescue StandardError => e
1096
- report(e, "checking #{run.job}")
1196
+ report(e, "checking #{listed.job}")
1097
1197
  end
1098
1198
 
1099
1199
  # Each job on its own: one that cannot be evaluated (a stored schedule
@@ -1102,17 +1202,22 @@ module Cronwatch
1102
1202
  # the others.
1103
1203
  jobs = []
1104
1204
  retries = RetryBudget.new(0)
1105
- @store.list_jobs.each do |stored|
1205
+ stored_jobs.each do |stored|
1206
+ evaluable(stored.name, stored.definition)
1106
1207
  recent = @store.list_runs(stored.name, Evaluate::BASELINE_WINDOW)
1107
1208
  next_expected_at = nil
1108
- state, drafts = update_state(stored.name) do |previous|
1209
+ state, held = update_state(stored.name) do |previous|
1109
1210
  evaluation = Evaluate.on_check(stored.definition, stored, recent.first, previous, at)
1110
1211
  next_expected_at = evaluation.next_expected_at
1111
1212
  settled = Evaluate.apply_silence(previous, evaluation, at)
1112
- [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)]
1113
1217
  end
1218
+ report_dropped(stored.name, held.dropped)
1114
1219
  alerts.concat(retry_undelivered(stored.name, state, at, retries))
1115
- alerts.concat(dispatch(drafts, stored.definition, at))
1220
+ alerts.concat(dispatch(stored.name, held.alerts, at))
1116
1221
  jobs << Evaluate.summarize(stored, recent, state, next_expected_at, at)
1117
1222
  rescue StandardError => e
1118
1223
  report(e, "checking #{stored.name}")
@@ -1139,6 +1244,7 @@ module Cronwatch
1139
1244
  recent = []
1140
1245
  begin
1141
1246
  recent = @store.list_runs(stored.name, [count, Evaluate::BASELINE_WINDOW].max)
1247
+ evaluable(stored.name, stored.definition)
1142
1248
  state = read_state(stored.name)
1143
1249
  next_expected_at = Evaluate.on_check(stored.definition, stored, recent.first, state, at).next_expected_at
1144
1250
  JobWithRuns.new(job: Evaluate.summarize(stored, recent, state, next_expected_at, at), runs: recent.first(count))
@@ -1148,6 +1254,13 @@ module Cronwatch
1148
1254
  end
1149
1255
  end
1150
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
+
1151
1264
  # The summary of a job whose evaluation failed, from whatever can still be read.
1152
1265
  def unevaluable(stored, at)
1153
1266
  recent = begin
@@ -1174,27 +1287,25 @@ module Cronwatch
1174
1287
  state
1175
1288
  end
1176
1289
 
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?
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
1182
1300
 
1183
- composed = []
1184
1301
  delivered = []
1185
1302
  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
1303
+ alerts.each do |alert|
1304
+ add_triage(alert, @triage_timeout_ms) if @triage && alert.type != :recovered
1305
+ (deliver(alert) ? delivered : failed) << alert
1195
1306
  end
1196
- record_delivery(definition.name, delivered, failed, [], at)
1197
- composed
1307
+ record_delivery(name, delivered, failed, [], at)
1308
+ alerts
1198
1309
  end
1199
1310
 
1200
1311
  # The wall-clock milliseconds one check has spent retrying, across its jobs.
@@ -1230,24 +1341,16 @@ module Cronwatch
1230
1341
  end
1231
1342
 
1232
1343
  # 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.
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.
1235
1348
  def record_delivery(name, delivered, failed, dropped, at)
1236
1349
  _, 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}")
1350
+ held = Evaluate.record_sent(Evaluate.normalize_state(previous, name), delivered, failed, dropped, at)
1351
+ [held.state, held.dropped]
1250
1352
  end
1353
+ report_dropped(name, trimmed)
1251
1354
  rescue StandardError => e
1252
1355
  report(e, "recording alert delivery for #{name}")
1253
1356
  end
@@ -1272,14 +1375,20 @@ module Cronwatch
1272
1375
  Thread.current.report_on_exception = false
1273
1376
  send_to(channel, alert)
1274
1377
  outcomes[i] = true
1275
- 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
1276
1382
  outcomes[i] = e
1277
1383
  end
1278
1384
  end
1279
1385
  deadline = AbortSignal.monotonic + (@channel_timeout_ms / 1000.0)
1280
1386
  results = threads.each_with_index.map do |thread, i|
1281
1387
  next outcomes[i] if thread.nil?
1282
- 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
1283
1392
 
1284
1393
  @sending_lock.synchronize { @abandoned[i] = thread }
1285
1394
  TimeoutError.new("timed out after #{@channel_timeout_ms}ms")
@@ -1334,21 +1443,28 @@ module Cronwatch
1334
1443
  thread = Thread.new do
1335
1444
  Thread.current.report_on_exception = false
1336
1445
  outcome = [:ok, @triage.call(context)]
1337
- 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
1338
1448
  outcome = [:error, e]
1339
1449
  end
1340
1450
  unless thread.join(timeout_ms / 1000.0)
1341
1451
  @sending_lock.synchronize { @abandoned[:triage] = thread }
1342
1452
  raise TimeoutError, "timed out after #{timeout_ms}ms"
1343
1453
  end
1344
- 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
1345
1456
 
1346
1457
  diagnosis = outcome[1]
1347
1458
  alert.triage_result = diagnosis.is_a?(String) && !diagnosis.empty? ? Output.utf8(diagnosis) : nil
1348
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)
1349
1465
  signal&.abort!
1350
1466
  alert.triage_result = nil
1351
- report(e, "triage for #{alert.job}")
1467
+ report(error, "triage for #{alert.job}")
1352
1468
  end
1353
1469
 
1354
1470
  # A whole number in range, or the fallback for anything that is not a number.