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 +4 -4
- data/README.md +3 -3
- data/lib/cronwatch/alerts/discord.rb +15 -2
- data/lib/cronwatch/client.rb +209 -93
- data/lib/cronwatch/environment.rb +26 -0
- data/lib/cronwatch/evaluate.rb +105 -1
- data/lib/cronwatch/flight.rb +4 -0
- data/lib/cronwatch/job.rb +5 -2
- data/lib/cronwatch/output.rb +25 -1
- data/lib/cronwatch/pg_cron.rb +7 -3
- data/lib/cronwatch/scheduler.rb +73 -10
- data/lib/cronwatch/stores/active_record.rb +10 -2
- data/lib/cronwatch/ticker.rb +12 -0
- data/lib/cronwatch/types.rb +60 -5
- data/lib/cronwatch/version.rb +1 -1
- data/lib/cronwatch/web/app.rb +6 -4
- data/lib/cronwatch/web/html.rb +3 -2
- data/lib/generators/cronwatch/install/install_generator.rb +51 -7
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: cb5e3c0550792cfd9ba9d34f828cf099051f30b5b52cfd780373efe581e665e2
|
|
4
|
+
data.tar.gz: ba21834f9a3d3b8b641e26fa23990f34f12f2190b7ad5bbf7d9355b0bdc0685f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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`")
|
data/lib/cronwatch/client.rb
CHANGED
|
@@ -25,7 +25,7 @@ module Cronwatch
|
|
|
25
25
|
CHANNEL_TIMEOUT_MS = 15_000
|
|
26
26
|
PRUNE_INTERVAL_MS = 60 * 60_000
|
|
27
27
|
# Undelivered alerts kept per job for retry; the oldest go first.
|
|
28
|
-
MAX_UNDELIVERED =
|
|
28
|
+
MAX_UNDELIVERED = Evaluate::MAX_UNDELIVERED
|
|
29
29
|
# Wall-clock time one check spends retrying undelivered alerts, across
|
|
30
30
|
# every job. Once it is spent the rest wait for the next check.
|
|
31
31
|
RETRY_BUDGET_MS = 20_000
|
|
@@ -198,7 +198,7 @@ module Cronwatch
|
|
|
198
198
|
duration_ms: nil, error: nil, output: nil, metrics: {}, trigger: trigger)
|
|
199
199
|
recorded = false
|
|
200
200
|
begin
|
|
201
|
-
sync(definition)
|
|
201
|
+
sync(definition, confirm: true)
|
|
202
202
|
@store.insert_run(run.dup)
|
|
203
203
|
recorded = true
|
|
204
204
|
rescue StandardError => e
|
|
@@ -232,7 +232,7 @@ module Cronwatch
|
|
|
232
232
|
run.duration_ms = Evaluate.run_duration(started_at, finished_at)
|
|
233
233
|
run.metrics = recorder.metrics
|
|
234
234
|
returned = result.is_a?(String) ? Output.utf8(result) : nil
|
|
235
|
-
run.output = recorder.output ||
|
|
235
|
+
run.output = recorder.output || returned
|
|
236
236
|
|
|
237
237
|
conclude(definition, run, result, error, threw, recorder.expect_text || returned, failure: failure)
|
|
238
238
|
begin
|
|
@@ -316,9 +316,10 @@ module Cronwatch
|
|
|
316
316
|
# alone and reported. A finished run is judged as if it had been wrapped
|
|
317
317
|
# here (expect, failures, duration, budgets) and its output and error are
|
|
318
318
|
# redacted the same way; one finishing after a check marked it timeout is
|
|
319
|
-
# judged only when it succeeded, as RunHandle#finish does.
|
|
320
|
-
#
|
|
321
|
-
#
|
|
319
|
+
# judged only when it succeeded, as RunHandle#finish does. A metric that
|
|
320
|
+
# is not a finite number raises ArgumentError before anything is written,
|
|
321
|
+
# as JobContext#metric does. `evaluate: false` stores it without judging
|
|
322
|
+
# it, for history imported on first sight. Returns the alerts it sent.
|
|
322
323
|
#
|
|
323
324
|
# `run` is a Cronwatch::Run, or a hash of its fields (camelCase or snake_case keys).
|
|
324
325
|
def record_run(run, evaluate: true)
|
|
@@ -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.
|
|
347
|
-
run.error = Output.
|
|
357
|
+
run.output = Output.redact_and_cap(run.output, @redact) unless run.output.nil?
|
|
358
|
+
run.error = Output.redact_and_cap(run.error, @redact) unless run.error.nil?
|
|
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
|
-
|
|
428
|
+
jobs = stored_jobs
|
|
418
429
|
at = now
|
|
419
430
|
count = clamp_limit(limit, 20, 0)
|
|
420
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
817
|
-
|
|
818
|
-
run.
|
|
882
|
+
# logged, and before the cap, so the cut cannot keep half a secret. NULs
|
|
883
|
+
# go last, so not even a custom redact can store one.
|
|
884
|
+
run.output = Output.redact_and_cap(run.output, @redact) unless run.output.nil?
|
|
885
|
+
run.error = Output.redact_and_cap(run.error, @redact) unless run.error.nil?
|
|
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 ||
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
1109
|
+
held = nil
|
|
1041
1110
|
begin
|
|
1042
1111
|
past = nil
|
|
1043
|
-
_,
|
|
1112
|
+
_, held = update_state(run.job) do |previous|
|
|
1044
1113
|
past ||= history(run)
|
|
1045
|
-
|
|
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
|
-
|
|
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 |
|
|
1082
|
-
declared = @registry.synchronize { @definitions[
|
|
1083
|
-
definition = declared ? Serialize.to_stored(declared) : @store.get_job(
|
|
1084
|
-
next if definition.nil?
|
|
1173
|
+
@store.running_runs.each do |listed|
|
|
1174
|
+
declared = @registry.synchronize { @definitions[listed.job] }
|
|
1175
|
+
definition = declared ? Serialize.to_stored(declared) : @store.get_job(listed.job)&.definition
|
|
1176
|
+
next if definition.nil?
|
|
1177
|
+
|
|
1178
|
+
evaluable(listed.job, definition)
|
|
1179
|
+
next unless Evaluate.stuck?(definition, listed, at)
|
|
1180
|
+
|
|
1181
|
+
# Read again just before the write: lines and metrics flushed since the
|
|
1182
|
+
# list was read (while earlier stuck runs were sent, say) are kept.
|
|
1183
|
+
run = @store.get_run(listed.id)
|
|
1184
|
+
next if run.nil? || run.status != :running || run.job != listed.job
|
|
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 #{
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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(
|
|
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
|
-
#
|
|
1178
|
-
#
|
|
1179
|
-
# the delivery fields are written back,
|
|
1180
|
-
|
|
1181
|
-
|
|
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
|
-
|
|
1187
|
-
alert
|
|
1188
|
-
|
|
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(
|
|
1197
|
-
|
|
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
|
|
1234
|
-
#
|
|
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
|
-
|
|
1238
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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.
|