cronwatch 0.3.1
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 +7 -0
- data/LICENSE +21 -0
- data/README.md +268 -0
- data/lib/cronwatch/abort_signal.rb +45 -0
- data/lib/cronwatch/active_record.rb +11 -0
- data/lib/cronwatch/alerts/console.rb +22 -0
- data/lib/cronwatch/alerts/custom.rb +26 -0
- data/lib/cronwatch/alerts/discord.rb +52 -0
- data/lib/cronwatch/alerts/slack.rb +58 -0
- data/lib/cronwatch/alerts/webhook.rb +46 -0
- data/lib/cronwatch/client.rb +925 -0
- data/lib/cronwatch/cron_pattern.rb +277 -0
- data/lib/cronwatch/duration.rb +77 -0
- data/lib/cronwatch/environment.rb +29 -0
- data/lib/cronwatch/evaluate.rb +345 -0
- data/lib/cronwatch/flight.rb +42 -0
- data/lib/cronwatch/format.rb +89 -0
- data/lib/cronwatch/http.rb +52 -0
- data/lib/cronwatch/job.rb +144 -0
- data/lib/cronwatch/js.rb +188 -0
- data/lib/cronwatch/monitored.rb +259 -0
- data/lib/cronwatch/output.rb +199 -0
- data/lib/cronwatch/rails/active_job.rb +55 -0
- data/lib/cronwatch/rails/check_job.rb +32 -0
- data/lib/cronwatch/rails/railtie.rb +37 -0
- data/lib/cronwatch/rails/tasks.rb +12 -0
- data/lib/cronwatch/rails.rb +35 -0
- data/lib/cronwatch/schedule.rb +191 -0
- data/lib/cronwatch/scheduler.rb +763 -0
- data/lib/cronwatch/serialize.rb +51 -0
- data/lib/cronwatch/sidekiq.rb +129 -0
- data/lib/cronwatch/stats.rb +23 -0
- data/lib/cronwatch/stores/active_record.rb +397 -0
- data/lib/cronwatch/stores/memory.rb +163 -0
- data/lib/cronwatch/ticker.rb +59 -0
- data/lib/cronwatch/triage/anthropic.rb +134 -0
- data/lib/cronwatch/types.rb +341 -0
- data/lib/cronwatch/version.rb +6 -0
- data/lib/cronwatch/walker.rb +137 -0
- data/lib/cronwatch/web/app.rb +484 -0
- data/lib/cronwatch/web/html.rb +314 -0
- data/lib/cronwatch/web.rb +17 -0
- data/lib/cronwatch/zone.rb +72 -0
- data/lib/cronwatch.rb +96 -0
- data/lib/generators/cronwatch/install/install_generator.rb +176 -0
- metadata +104 -0
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/concern"
|
|
4
|
+
require "active_support/inflector"
|
|
5
|
+
require "active_job"
|
|
6
|
+
require_relative "../scheduler"
|
|
7
|
+
|
|
8
|
+
module Cronwatch
|
|
9
|
+
# Monitors an ActiveJob class. Each perform is a recorded run with the
|
|
10
|
+
# trigger "active_job"; `cronwatch` in the job is the run's context, for
|
|
11
|
+
# log and metric. A perform that raises is recorded as failed and then
|
|
12
|
+
# raises as before, so retry_on, discard_on and error reporters see it
|
|
13
|
+
# unchanged.
|
|
14
|
+
#
|
|
15
|
+
# class NightlyReportJob < ApplicationJob
|
|
16
|
+
# include Cronwatch::ActiveJob
|
|
17
|
+
# cronwatch schedule: "0 2 * * *", grace: "15m", expect: "Report written" # name: "nightly-report"
|
|
18
|
+
#
|
|
19
|
+
# def perform
|
|
20
|
+
# cronwatch.log("Report written")
|
|
21
|
+
# cronwatch.metric(:cost, 1.2)
|
|
22
|
+
# end
|
|
23
|
+
# end
|
|
24
|
+
#
|
|
25
|
+
# `cronwatch schedule: :from_scheduler` takes the schedule from the class's
|
|
26
|
+
# entry in config/recurring.yml (Solid Queue) or sidekiq-cron's schedule,
|
|
27
|
+
# so the cron expression is written once.
|
|
28
|
+
#
|
|
29
|
+
# The job is declared on Cronwatch.client once the app has booted (after
|
|
30
|
+
# config/initializers/cronwatch.rb ran), and again if the client is
|
|
31
|
+
# replaced. Only classes that call `cronwatch` are monitored; subclasses
|
|
32
|
+
# declare their own.
|
|
33
|
+
module ActiveJob
|
|
34
|
+
extend ActiveSupport::Concern
|
|
35
|
+
include Cronwatch::Monitored
|
|
36
|
+
|
|
37
|
+
TRIGGER = "active_job"
|
|
38
|
+
|
|
39
|
+
included do
|
|
40
|
+
around_perform :cronwatch_perform
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
class_methods do
|
|
44
|
+
include Cronwatch::Monitored::ClassMethods
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
# Runs the perform as a recorded run. A job whose declaration is broken
|
|
50
|
+
# still performs, unrecorded, with the error sent to on_error.
|
|
51
|
+
def cronwatch_perform(&block)
|
|
52
|
+
Cronwatch::Monitored.record(self.class.cronwatch_declaration, TRIGGER, self, &block)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_job"
|
|
4
|
+
|
|
5
|
+
module Cronwatch
|
|
6
|
+
# Looks for missed and stuck runs across every job, sends alerts, retries
|
|
7
|
+
# undelivered ones and prunes old runs: Cronwatch.client.check, as a job.
|
|
8
|
+
# Schedule it every few minutes; nothing else notices a job that never ran.
|
|
9
|
+
#
|
|
10
|
+
# # config/recurring.yml (Solid Queue)
|
|
11
|
+
# production:
|
|
12
|
+
# cronwatch_check:
|
|
13
|
+
# class: Cronwatch::CheckJob
|
|
14
|
+
# schedule: every 5 minutes
|
|
15
|
+
#
|
|
16
|
+
# # config/schedule.yml (sidekiq-cron)
|
|
17
|
+
# cronwatch_check:
|
|
18
|
+
# cron: "*/5 * * * *"
|
|
19
|
+
# class: "Cronwatch::CheckJob"
|
|
20
|
+
#
|
|
21
|
+
# With Sidekiq but no ActiveJob adapter for it, Cronwatch::Sidekiq::CheckWorker
|
|
22
|
+
# does the same. Returns the CheckResult.
|
|
23
|
+
class CheckJob < ::ActiveJob::Base
|
|
24
|
+
queue_as :default
|
|
25
|
+
|
|
26
|
+
def perform
|
|
27
|
+
Cronwatch::Monitored.load_app_jobs
|
|
28
|
+
Cronwatch::Monitored.register_all(strict: false)
|
|
29
|
+
Cronwatch.client.check
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/railtie"
|
|
4
|
+
|
|
5
|
+
module Cronwatch
|
|
6
|
+
# Hooks the gem into a Rails app. There is nothing to start: runs are
|
|
7
|
+
# recorded as monitored jobs perform, and checks come from
|
|
8
|
+
# Cronwatch::CheckJob (or Cronwatch::Sidekiq::CheckWorker) on your
|
|
9
|
+
# scheduler, so no interval thread runs inside web or worker processes.
|
|
10
|
+
# Errors outside jobs (the store, a channel) go to Rails.logger unless the
|
|
11
|
+
# initializer sets on_error.
|
|
12
|
+
class Railtie < ::Rails::Railtie
|
|
13
|
+
# With Sidekiq in the bundle, its server records the runs of
|
|
14
|
+
# Cronwatch::Sidekiq jobs. Checked here rather than when the gem loads,
|
|
15
|
+
# so the order of the Gemfile does not matter.
|
|
16
|
+
initializer "cronwatch.sidekiq" do
|
|
17
|
+
if defined?(::Sidekiq) && ::Sidekiq.respond_to?(:configure_server)
|
|
18
|
+
require "cronwatch/sidekiq"
|
|
19
|
+
Cronwatch::Sidekiq.install
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Once config/initializers/cronwatch.rb has run and, in production, app/jobs
|
|
24
|
+
# is loaded: declare every monitored job, and what
|
|
25
|
+
# Cronwatch.declare_from_scheduler! asked for, so a check knows each
|
|
26
|
+
# schedule before the job first runs. A bad declaration (or a schedule
|
|
27
|
+
# that cannot be read from the scheduler's config) stops the boot, as it
|
|
28
|
+
# would in plain Ruby.
|
|
29
|
+
config.after_initialize do
|
|
30
|
+
Cronwatch::Monitored.boot!
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
rake_tasks do
|
|
34
|
+
require_relative "tasks"
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# `bin/rails cronwatch:check`: one check, for a plain crontab instead of a job scheduler.
|
|
4
|
+
namespace :cronwatch do
|
|
5
|
+
desc "Check for missed and stuck runs and send alerts (what Cronwatch::CheckJob does)"
|
|
6
|
+
task check: :environment do
|
|
7
|
+
result = Cronwatch::CheckJob.perform_now
|
|
8
|
+
jobs = result.jobs.length
|
|
9
|
+
alerts = result.alerts.length
|
|
10
|
+
puts "cronwatch: checked #{jobs} job#{jobs == 1 ? "" : "s"}, sent #{alerts} alert#{alerts == 1 ? "" : "s"}"
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# The Rails integration: a Railtie, the Cronwatch::ActiveJob concern,
|
|
4
|
+
# Cronwatch::CheckJob, Cronwatch::Web and the `cronwatch:install` generator.
|
|
5
|
+
# Needs railties and activejob; the ActiveRecord store loads on first use,
|
|
6
|
+
# and so does Cronwatch::Sidekiq when the app has Sidekiq (the Railtie loads
|
|
7
|
+
# it at boot).
|
|
8
|
+
#
|
|
9
|
+
# `gem "cronwatch"` loads this file on its own when Rails is already loaded,
|
|
10
|
+
# as it is under Bundler.require; require it by hand only otherwise.
|
|
11
|
+
require "cronwatch" unless defined?(Cronwatch::Client) # loaded from cronwatch.rb when Rails is already up
|
|
12
|
+
begin
|
|
13
|
+
require "rails"
|
|
14
|
+
require "active_job"
|
|
15
|
+
rescue LoadError => e
|
|
16
|
+
raise LoadError, "cronwatch/rails needs the railties and activejob gems, which a Rails app has (#{e.message})"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Rails always brings Rack, so the dashboard loads here and the mount line in
|
|
20
|
+
# config/routes.rb needs no require of its own.
|
|
21
|
+
require_relative "web"
|
|
22
|
+
|
|
23
|
+
module Cronwatch
|
|
24
|
+
autoload :Sidekiq, File.expand_path("sidekiq", __dir__) unless const_defined?(:Sidekiq, false)
|
|
25
|
+
|
|
26
|
+
module Stores
|
|
27
|
+
autoload :ActiveRecord, File.expand_path("stores/active_record", __dir__) unless const_defined?(:ActiveRecord, false)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
require_relative "sidekiq" if defined?(::Sidekiq::Job) || defined?(::Sidekiq::Worker)
|
|
32
|
+
|
|
33
|
+
require_relative "rails/active_job"
|
|
34
|
+
require_relative "rails/check_job"
|
|
35
|
+
require_relative "rails/railtie"
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fugit"
|
|
4
|
+
require_relative "zone"
|
|
5
|
+
require_relative "cron_pattern"
|
|
6
|
+
|
|
7
|
+
module Cronwatch
|
|
8
|
+
# Schedules: "0 2 * * *" (cron, five or six fields), "@hourly", or
|
|
9
|
+
# "every 5m". The SDK computes fire times with croner; this module gives
|
|
10
|
+
# the same times. Fugit parses the cron fields. Croner's rules decide what
|
|
11
|
+
# is accepted and how a fire lands on a clock that jumps (daylight saving),
|
|
12
|
+
# so both are ported here and a Node and a Ruby process sharing one store
|
|
13
|
+
# agree on every due time.
|
|
14
|
+
module Schedule
|
|
15
|
+
# How early a run may start and still count for the fire it was meant for.
|
|
16
|
+
EARLY_SLACK_MS = 60_000
|
|
17
|
+
|
|
18
|
+
Parsed = Struct.new(:kind, :source, :timezone, :every_ms, keyword_init: true) do
|
|
19
|
+
include Serializable
|
|
20
|
+
|
|
21
|
+
# The compiled cron fields, kept out of the public shape.
|
|
22
|
+
attr_accessor :pattern
|
|
23
|
+
|
|
24
|
+
def cron? = kind == :cron
|
|
25
|
+
def interval? = kind == :interval
|
|
26
|
+
|
|
27
|
+
def to_h
|
|
28
|
+
out = { "kind" => kind.to_s, "source" => source }
|
|
29
|
+
out["timezone"] = timezone if timezone
|
|
30
|
+
out["everyMs"] = every_ms if every_ms
|
|
31
|
+
out
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
Expectation = Struct.new(:due_at, :deadline, keyword_init: true) do
|
|
36
|
+
include Serializable
|
|
37
|
+
|
|
38
|
+
def to_h = { "dueAt" => due_at, "deadline" => deadline }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
@cache = {}
|
|
42
|
+
@lock = Mutex.new
|
|
43
|
+
|
|
44
|
+
module_function
|
|
45
|
+
|
|
46
|
+
# Parsed once per (schedule, timezone) pair and cached. Without a timezone
|
|
47
|
+
# the expression is read in the process timezone, like crontab. Vercel and
|
|
48
|
+
# GitHub Actions run their crons in UTC, so pass timezone: "UTC" for those.
|
|
49
|
+
def parse(schedule, timezone = nil)
|
|
50
|
+
key = "#{timezone}|#{schedule}"
|
|
51
|
+
hit = @lock.synchronize { @cache[key] }
|
|
52
|
+
return hit if hit
|
|
53
|
+
|
|
54
|
+
text = JS.trim(schedule.to_s)
|
|
55
|
+
every = Regexp.new("\\Aevery[#{JS::WHITESPACE}]+(.+)\\z", Regexp::IGNORECASE).match(text)
|
|
56
|
+
if every
|
|
57
|
+
every_ms = Duration.parse(every[1], "schedule interval")
|
|
58
|
+
raise ArgumentError, "schedule \"#{schedule}\" is shorter than one second" if every_ms < 1000
|
|
59
|
+
|
|
60
|
+
parsed = Parsed.new(kind: :interval, source: text, every_ms: every_ms)
|
|
61
|
+
else
|
|
62
|
+
begin
|
|
63
|
+
pattern = CronPattern.compile(text)
|
|
64
|
+
rescue ArgumentError => e
|
|
65
|
+
raise ArgumentError, "schedule \"#{schedule}\" is not a cron expression or \"every <duration>\": #{e.message}"
|
|
66
|
+
end
|
|
67
|
+
parsed = Parsed.new(kind: :cron, source: text, timezone: timezone.nil? || timezone == "" ? nil : timezone)
|
|
68
|
+
parsed.pattern = pattern
|
|
69
|
+
end
|
|
70
|
+
parsed.freeze
|
|
71
|
+
@lock.synchronize { @cache[key] = parsed }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# The first fire strictly after `from`, or nil when the cron never fires
|
|
75
|
+
# again. The walker works on the wall clock and Zone.to_utc takes the
|
|
76
|
+
# earlier of a repeated time, so, like croner, asked from inside the hour
|
|
77
|
+
# that repeats when clocks go back it can answer with times in the past.
|
|
78
|
+
# Its answers are filtered, and a stretch of nothing but past times is
|
|
79
|
+
# stepped over an hour at a time. Ported from fireAfter in schedule.ts.
|
|
80
|
+
def fire_after(parsed, from)
|
|
81
|
+
raise ArgumentError, "schedule \"#{parsed.source}\" was not made by Schedule.parse" unless parsed.pattern
|
|
82
|
+
|
|
83
|
+
probe = from
|
|
84
|
+
4.times do
|
|
85
|
+
runs = next_runs(parsed, 8, probe)
|
|
86
|
+
return nil if runs.empty?
|
|
87
|
+
|
|
88
|
+
found = runs.find { |t| t > from }
|
|
89
|
+
return found if found
|
|
90
|
+
|
|
91
|
+
probe += 3_600_000
|
|
92
|
+
end
|
|
93
|
+
nil
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Up to `count` fires, each found from the one before, as croner's nextRuns.
|
|
97
|
+
def next_runs(parsed, count, from)
|
|
98
|
+
runs = []
|
|
99
|
+
at = from
|
|
100
|
+
count.times do
|
|
101
|
+
at = parsed.pattern.next_after(at, parsed.timezone)
|
|
102
|
+
break if at.nil?
|
|
103
|
+
|
|
104
|
+
runs << at
|
|
105
|
+
end
|
|
106
|
+
runs
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# The next time the schedule fires strictly after `from`. For an interval, counted from the last run when there is one.
|
|
110
|
+
def next_fire(parsed, from, last_run_at)
|
|
111
|
+
return (last_run_at || from) + parsed.every_ms if parsed.interval?
|
|
112
|
+
|
|
113
|
+
fire_after(parsed, from)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# When the schedule next wants a run, given the last one. For a cron that is
|
|
117
|
+
# the first fire the last run does not already cover; with no run yet, the
|
|
118
|
+
# first fire at or after registration. For an interval it is the last run's
|
|
119
|
+
# start (or registration) plus the interval. Nil for a cron that never
|
|
120
|
+
# fires again.
|
|
121
|
+
#
|
|
122
|
+
# Counting forward from the last run, rather than back from now, is what
|
|
123
|
+
# lets a job whose period is shorter than its grace be missed at all, and it
|
|
124
|
+
# works for a cron that fires once a year or less.
|
|
125
|
+
def expectation(parsed, last_run_at, registered_at, grace_ms)
|
|
126
|
+
due_at =
|
|
127
|
+
if parsed.interval? then (last_run_at || registered_at) + parsed.every_ms
|
|
128
|
+
elsif last_run_at.nil? then fire_after(parsed, registered_at - 1)
|
|
129
|
+
else due_after_run(parsed, last_run_at)
|
|
130
|
+
end
|
|
131
|
+
due_at.nil? ? nil : Expectation.new(due_at: due_at, deadline: due_at + grace_ms)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# The first fire that a run starting at `started_at` does not cover.
|
|
135
|
+
def due_after_run(parsed, started_at)
|
|
136
|
+
# A fire at or before the start is covered by the run itself.
|
|
137
|
+
following_fire = fire_after(parsed, started_at)
|
|
138
|
+
return nil if following_fire.nil?
|
|
139
|
+
|
|
140
|
+
following = fire_after(parsed, following_fire)
|
|
141
|
+
covers = run_covers?(started_at, following_fire, following) || in_spring_forward_gap?(parsed, started_at, following_fire)
|
|
142
|
+
covers ? following : following_fire
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Whether a run starting at `started_at` covers the fire at `due_at`. A minute
|
|
146
|
+
# of slack before the tick absorbs schedulers that fire a touch early. When
|
|
147
|
+
# the fire after `due_at` is known, the slack is at most half the gap between
|
|
148
|
+
# the two, so one run of an every-minute cron never covers two fires.
|
|
149
|
+
def run_covers?(started_at, due_at, following_at = nil)
|
|
150
|
+
slack = following_at.nil? ? EARLY_SLACK_MS : [EARLY_SLACK_MS, (following_at - due_at).div(2)].min
|
|
151
|
+
started_at >= due_at - slack
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# On the night clocks spring forward, a fire whose local time does not exist
|
|
155
|
+
# (02:30 when 02:00 jumps to 03:00) is moved by croner to the same distance
|
|
156
|
+
# past the jump (03:30), while vixie cron runs it at the jump itself (03:00).
|
|
157
|
+
# A run that starts at or after the jump, and before the first fire after
|
|
158
|
+
# it when that fire lies within one gap of it, is taken to cover that fire,
|
|
159
|
+
# so neither scheduler's run is reported as missed. A cron that really fires
|
|
160
|
+
# at 03:30 that night is treated the same way, which only matters if it also
|
|
161
|
+
# ran early by up to an hour.
|
|
162
|
+
def in_spring_forward_gap?(parsed, started_at, fire_at)
|
|
163
|
+
lookback = 3 * 3_600_000
|
|
164
|
+
after = utc_offset(fire_at, parsed.timezone)
|
|
165
|
+
before = utc_offset(fire_at - lookback, parsed.timezone)
|
|
166
|
+
gap = after - before
|
|
167
|
+
return false if gap <= 0
|
|
168
|
+
|
|
169
|
+
# Find the jump: the first minute in the window with the later offset.
|
|
170
|
+
lo = fire_at - lookback
|
|
171
|
+
hi = fire_at
|
|
172
|
+
while hi - lo > 60_000
|
|
173
|
+
mid = lo + (hi - lo).div(2)
|
|
174
|
+
if utc_offset(mid, parsed.timezone) == after then hi = mid
|
|
175
|
+
else lo = mid
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
jump_at = hi.div(60_000) * 60_000
|
|
179
|
+
return false if fire_at - jump_at >= gap || started_at < jump_at - EARLY_SLACK_MS || started_at >= fire_at
|
|
180
|
+
|
|
181
|
+
# Only the first fire after the jump can be a moved one; a cron that also
|
|
182
|
+
# fires at the jump (every 10 minutes, say) was not moved at all.
|
|
183
|
+
fire_after(parsed, jump_at - 1) == fire_at
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Milliseconds the zone's wall clock is ahead of UTC at `at`.
|
|
187
|
+
def utc_offset(at, timezone)
|
|
188
|
+
Zone.offset(at.div(1000), timezone) * 1000
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|