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,763 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
require "erb"
|
|
5
|
+
require "yaml"
|
|
6
|
+
require "fugit"
|
|
7
|
+
require "cronwatch" unless defined?(Cronwatch::Client)
|
|
8
|
+
require_relative "monitored"
|
|
9
|
+
|
|
10
|
+
module Cronwatch
|
|
11
|
+
# Reads schedules from the scheduler's own config, so a job's cron
|
|
12
|
+
# expression is written once: Solid Queue's config/recurring.yml and
|
|
13
|
+
# sidekiq-cron's schedule. Both parse schedules with Fugit, which also
|
|
14
|
+
# takes phrases such as "every day at 3am"; each is turned into the cron
|
|
15
|
+
# expression and timezone CronWatch reads, or refused with an error when
|
|
16
|
+
# CronWatch would not expect runs at exactly the times the scheduler makes
|
|
17
|
+
# them.
|
|
18
|
+
#
|
|
19
|
+
# cronwatch schedule: :from_scheduler # in a Cronwatch::ActiveJob or Cronwatch::Sidekiq class
|
|
20
|
+
# Cronwatch.declare_from_scheduler! # every entry, once the app has booted
|
|
21
|
+
#
|
|
22
|
+
# The sources are found on their own (Solid Queue's file when Solid Queue
|
|
23
|
+
# is loaded, sidekiq-cron's when it is) or set by hand:
|
|
24
|
+
#
|
|
25
|
+
# Cronwatch::Scheduler.sources = [Cronwatch::Scheduler::SidekiqCron.new("config/cron.yml")]
|
|
26
|
+
module Scheduler
|
|
27
|
+
# A schedule that cannot be read, found or converted exactly.
|
|
28
|
+
class Error < ArgumentError; end
|
|
29
|
+
|
|
30
|
+
# One recurring entry as the scheduler's config gives it. `schedule` is
|
|
31
|
+
# the text as written; `class_name` or `command` says what runs.
|
|
32
|
+
Entry = Struct.new(:source, :key, :class_name, :command, :schedule, :description, :disabled, keyword_init: true) do
|
|
33
|
+
def label
|
|
34
|
+
"#{source.label} #{key}"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# { schedule:, timezone: } for Cronwatch::Client#job. Raises Error.
|
|
38
|
+
def convert
|
|
39
|
+
source.convert(self)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# How far ahead the daylight saving check looks, and how far either side
|
|
44
|
+
# of each clock change it compares the scheduler's runs with CronWatch's.
|
|
45
|
+
HORIZON_YEARS = 5
|
|
46
|
+
CHANGE_WINDOW_MS = 2 * 86_400_000
|
|
47
|
+
# Away from clock changes, SAMPLE_RUNS runs from the start of each month
|
|
48
|
+
# of a fixed year are compared too.
|
|
49
|
+
SAMPLE_YEAR = 2026
|
|
50
|
+
SAMPLE_MONTHS = 12
|
|
51
|
+
SAMPLE_RUNS = 8
|
|
52
|
+
|
|
53
|
+
# Where a schedule's file is read from, and how its entries are parsed.
|
|
54
|
+
class Source
|
|
55
|
+
attr_reader :config
|
|
56
|
+
|
|
57
|
+
def initialize(config, label)
|
|
58
|
+
@config = config
|
|
59
|
+
@label = label
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# config/recurring.yml, relative to the app's root when it is inside it.
|
|
63
|
+
def label
|
|
64
|
+
return @label if @label
|
|
65
|
+
return "the #{name} config" unless path
|
|
66
|
+
|
|
67
|
+
root = Scheduler.root.to_s
|
|
68
|
+
path.start_with?("#{root}/") ? path.delete_prefix("#{root}/") : path
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def path
|
|
72
|
+
@config.is_a?(Hash) || @config.is_a?(Array) ? nil : File.expand_path(@config.to_s, Scheduler.root.to_s)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def entries
|
|
76
|
+
raise NotImplementedError
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
# The config: the Hash or Array given, or the file after ERB, as both
|
|
82
|
+
# schedulers read it. Dates and times are allowed, as in a job's args.
|
|
83
|
+
def read
|
|
84
|
+
return @config if @config.is_a?(Hash) || @config.is_a?(Array)
|
|
85
|
+
return nil unless File.exist?(path)
|
|
86
|
+
|
|
87
|
+
parse_file
|
|
88
|
+
rescue SystemCallError, RuntimeError => e # Psych's errors, and ConfigurationFile's syntax error
|
|
89
|
+
raise Error, "cronwatch: could not read #{label}: #{e.message}"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def parse_file
|
|
93
|
+
YAML.safe_load(ERB.new(File.read(path)).result, aliases: true, permitted_classes: [Symbol, Date, Time])
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Fugit::Cron, and the IANA name of the zone the scheduler reads it in.
|
|
97
|
+
def finish(entry, cron, zone)
|
|
98
|
+
zone ||= Scheduler.local_zone_name(entry, name)
|
|
99
|
+
Scheduler.to_cronwatch(entry, cron, zone, name)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Solid Queue's recurring tasks: config/recurring.yml (or the file
|
|
104
|
+
# SOLID_QUEUE_RECURRING_SCHEDULE names), the section for the current
|
|
105
|
+
# environment when the file has one, each task a `class:` or a
|
|
106
|
+
# `command:` with a `schedule:`. Read as Solid Queue 1.x reads it. The
|
|
107
|
+
# file bin/jobs is given with --recurring_schedule_file is not seen:
|
|
108
|
+
# pass it as config, or set SOLID_QUEUE_RECURRING_SCHEDULE instead.
|
|
109
|
+
class SolidQueue < Source
|
|
110
|
+
# What ActiveModel::Type::Boolean casts to false.
|
|
111
|
+
FALSE_VALUES = %w[0 f F false FALSE off OFF].freeze
|
|
112
|
+
|
|
113
|
+
# config: a path (default: SOLID_QUEUE_RECURRING_SCHEDULE or
|
|
114
|
+
# config/recurring.yml under the app's root) or the parsed Hash.
|
|
115
|
+
# env: the section to read (default: Rails.env). time_zone: the zone a
|
|
116
|
+
# schedule without one is read in, an IANA name or a Rails one ("Eastern
|
|
117
|
+
# Time (US & Canada)"); by default Solid Queue's own
|
|
118
|
+
# (config.solid_queue.time_zone, which is config.time_zone unless set),
|
|
119
|
+
# and Fugit's local zone when that is nil or Solid Queue is older than
|
|
120
|
+
# 1.5. skip_recurring: true reads no tasks, as Solid Queue runs none; by
|
|
121
|
+
# default SOLID_QUEUE_SKIP_RECURRING, as Solid Queue reads it.
|
|
122
|
+
def initialize(config = nil, env: nil, time_zone: :auto, skip_recurring: :auto, label: nil)
|
|
123
|
+
super(config || ENV["SOLID_QUEUE_RECURRING_SCHEDULE"] || "config/recurring.yml", label)
|
|
124
|
+
@env = env
|
|
125
|
+
@time_zone = time_zone
|
|
126
|
+
@skip_recurring = skip_recurring
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def name = "Solid Queue"
|
|
130
|
+
|
|
131
|
+
def env
|
|
132
|
+
(@env || Scheduler.env).to_s
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def skip_recurring?
|
|
136
|
+
return @skip_recurring unless @skip_recurring == :auto
|
|
137
|
+
|
|
138
|
+
value = ENV.fetch("SOLID_QUEUE_SKIP_RECURRING", nil)
|
|
139
|
+
!(value.nil? || value.empty? || FALSE_VALUES.include?(value))
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def label
|
|
143
|
+
skip_recurring? ? "#{super} (not read: SOLID_QUEUE_SKIP_RECURRING is set)" : super
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def entries
|
|
147
|
+
return [] if skip_recurring?
|
|
148
|
+
|
|
149
|
+
config = read
|
|
150
|
+
return [] if config.nil?
|
|
151
|
+
raise Error, "cronwatch: #{label} is not a map of recurring tasks" unless config.is_a?(Hash)
|
|
152
|
+
|
|
153
|
+
config = symbolize(config)
|
|
154
|
+
config = config[env.to_sym] if config[env.to_sym]
|
|
155
|
+
return [] unless config.is_a?(Hash)
|
|
156
|
+
|
|
157
|
+
config.filter_map do |key, options|
|
|
158
|
+
next unless options.is_a?(Hash) && options.key?(:schedule)
|
|
159
|
+
|
|
160
|
+
Entry.new(source: self, key: key.to_s, class_name: present(options[:class]), command: present(options[:command]),
|
|
161
|
+
schedule: options[:schedule], description: present(options[:description]), disabled: false)
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def convert(entry)
|
|
166
|
+
cron = begin
|
|
167
|
+
Fugit.parse(entry.schedule.to_s, multi: :fail)
|
|
168
|
+
rescue ArgumentError => e
|
|
169
|
+
raise Error, "cronwatch: #{entry.label}: Solid Queue does not accept the schedule #{entry.schedule.to_s.inspect}: #{e.message}"
|
|
170
|
+
end
|
|
171
|
+
unless cron.instance_of?(Fugit::Cron)
|
|
172
|
+
raise Error, "cronwatch: #{entry.label}: #{entry.schedule.to_s.inspect} is not a recurring schedule Solid Queue accepts"
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
cron = with_default_zone(cron)
|
|
176
|
+
finish(entry, cron, cron.timezone&.name)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
private
|
|
180
|
+
|
|
181
|
+
# As SolidQueue::Configuration reads the file: ActiveSupport's
|
|
182
|
+
# ConfigurationFile (ERB, then YAML with any class) when it is there.
|
|
183
|
+
def parse_file
|
|
184
|
+
begin
|
|
185
|
+
require "active_support/configuration_file"
|
|
186
|
+
rescue LoadError
|
|
187
|
+
return super
|
|
188
|
+
end
|
|
189
|
+
::ActiveSupport::ConfigurationFile.parse(path)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def default_zone
|
|
193
|
+
zone = @time_zone
|
|
194
|
+
if zone == :auto
|
|
195
|
+
return nil unless defined?(::SolidQueue) && ::SolidQueue.respond_to?(:time_zone)
|
|
196
|
+
|
|
197
|
+
zone = ::SolidQueue.time_zone
|
|
198
|
+
end
|
|
199
|
+
return nil if zone.nil? || zone.to_s.empty?
|
|
200
|
+
|
|
201
|
+
iana_zone(zone)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# The IANA name for a zone given as SolidQueue.time_zone= takes it: an
|
|
205
|
+
# IANA name, a Rails name or an ActiveSupport::TimeZone.
|
|
206
|
+
def iana_zone(zone)
|
|
207
|
+
return zone.tzinfo.name if zone.respond_to?(:tzinfo)
|
|
208
|
+
return zone.to_s if Zone.valid?(zone.to_s)
|
|
209
|
+
|
|
210
|
+
found = (::ActiveSupport::TimeZone[zone.to_s] if defined?(::ActiveSupport::TimeZone))
|
|
211
|
+
return found.tzinfo.name if found
|
|
212
|
+
|
|
213
|
+
raise Error, "cronwatch: #{label}: the time zone #{zone.to_s.inspect} is neither an IANA timezone (such as " \
|
|
214
|
+
"America/New_York) nor a Rails time zone name (such as \"Eastern Time (US & Canada)\")"
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# As SolidQueue::RecurringTask#apply_default_time_zone_to. A zone that
|
|
218
|
+
# cannot be applied raises, rather than leave the schedule in another.
|
|
219
|
+
def with_default_zone(cron)
|
|
220
|
+
zone = default_zone
|
|
221
|
+
return cron unless cron.zone.nil? && zone
|
|
222
|
+
|
|
223
|
+
with = begin
|
|
224
|
+
Fugit.parse("#{cron.to_cron_s} #{zone}", multi: :fail)
|
|
225
|
+
rescue ArgumentError
|
|
226
|
+
nil
|
|
227
|
+
end
|
|
228
|
+
return with if with.is_a?(Fugit::Cron)
|
|
229
|
+
|
|
230
|
+
raise Error, "cronwatch: #{label}: #{cron.original.inspect} cannot be read in the time zone #{zone.inspect}"
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def symbolize(value)
|
|
234
|
+
case value
|
|
235
|
+
when Hash then value.each_with_object({}) { |(k, v), out| out[k.respond_to?(:to_sym) ? k.to_sym : k] = symbolize(v) }
|
|
236
|
+
when Array then value.map { |v| symbolize(v) }
|
|
237
|
+
else value
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def present(value)
|
|
242
|
+
value.nil? || value.to_s.strip.empty? ? nil : value.to_s
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# sidekiq-cron's schedule: config/schedule.yml (or the file its
|
|
247
|
+
# configuration names), a map of name to job or a list of jobs with
|
|
248
|
+
# `name:`, each with `cron:` and `class:`. A `cron:` may end in a
|
|
249
|
+
# timezone. Read as sidekiq-cron 1.x and 2.x read it; an entry with
|
|
250
|
+
# `status: disabled` is not scheduled.
|
|
251
|
+
class SidekiqCron < Source
|
|
252
|
+
# config: a path (default: sidekiq-cron's cron_schedule_file, or
|
|
253
|
+
# config/schedule.yml), or the Hash or Array given to
|
|
254
|
+
# Sidekiq::Cron::Job.load_from_hash / load_from_array. mode: how a
|
|
255
|
+
# natural-language schedule with several times is read, as
|
|
256
|
+
# sidekiq-cron's natural_cron_parsing_mode (:single keeps the first,
|
|
257
|
+
# :strict refuses it); by default sidekiq-cron's own setting.
|
|
258
|
+
def initialize(config = nil, mode: :auto, label: nil)
|
|
259
|
+
super(config || default_file, label)
|
|
260
|
+
@mode = mode
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def name = "sidekiq-cron"
|
|
264
|
+
|
|
265
|
+
# As sidekiq-cron, a missing .yml is looked for as .yaml.
|
|
266
|
+
def path
|
|
267
|
+
found = super
|
|
268
|
+
return found if found.nil? || File.exist?(found) || !found.end_with?(".yml")
|
|
269
|
+
|
|
270
|
+
yaml = found.sub(/\.yml\z/, ".yaml")
|
|
271
|
+
File.exist?(yaml) ? yaml : found
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def entries
|
|
275
|
+
config = read
|
|
276
|
+
return [] if config.nil?
|
|
277
|
+
|
|
278
|
+
jobs =
|
|
279
|
+
case config
|
|
280
|
+
when Hash then config.map { |key, job| job.is_a?(Hash) ? stringify(job).merge("name" => key.to_s) : { "name" => key.to_s } }
|
|
281
|
+
when Array then config.map { |job| job.is_a?(Hash) ? stringify(job) : {} }
|
|
282
|
+
else raise Error, "cronwatch: #{label} is not a map or list of cron jobs"
|
|
283
|
+
end
|
|
284
|
+
jobs.map do |job|
|
|
285
|
+
klass = job["class"] || job["klass"]
|
|
286
|
+
Entry.new(source: self, key: job["name"].to_s, class_name: klass&.to_s, command: nil, schedule: job["cron"],
|
|
287
|
+
description: job["description"]&.to_s, disabled: job["status"].to_s == "disabled")
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def convert(entry)
|
|
292
|
+
text = entry.schedule
|
|
293
|
+
raise Error, "cronwatch: #{entry.label}: sidekiq-cron needs a cron: string" unless text.is_a?(String) && !text.strip.empty?
|
|
294
|
+
|
|
295
|
+
cron = begin
|
|
296
|
+
if mode == :strict
|
|
297
|
+
Fugit.parse_cron(text) || Fugit.parse_nat(text, multi: :fail) || raise(ArgumentError, "invalid cron string #{text.inspect}")
|
|
298
|
+
else
|
|
299
|
+
Fugit.do_parse_cronish(text)
|
|
300
|
+
end
|
|
301
|
+
rescue ArgumentError => e
|
|
302
|
+
raise Error, "cronwatch: #{entry.label}: sidekiq-cron does not accept the cron #{text.inspect}: #{e.message}"
|
|
303
|
+
end
|
|
304
|
+
finish(entry, cron, cron.timezone&.name)
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
private
|
|
308
|
+
|
|
309
|
+
def mode
|
|
310
|
+
return @mode unless @mode == :auto
|
|
311
|
+
|
|
312
|
+
config = defined?(::Sidekiq::Cron) && ::Sidekiq::Cron.respond_to?(:configuration) && ::Sidekiq::Cron.configuration
|
|
313
|
+
config.respond_to?(:natural_cron_parsing_mode) ? config.natural_cron_parsing_mode : :single
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
def default_file
|
|
317
|
+
config = defined?(::Sidekiq::Cron) && ::Sidekiq::Cron.respond_to?(:configuration) && ::Sidekiq::Cron.configuration
|
|
318
|
+
(config.respond_to?(:cron_schedule_file) && config.cron_schedule_file) || "config/schedule.yml"
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
def stringify(hash)
|
|
322
|
+
hash.to_h { |k, v| [k.to_s, v] }
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
@sources = nil
|
|
327
|
+
@env = nil
|
|
328
|
+
@root = nil
|
|
329
|
+
@pending = nil
|
|
330
|
+
@lock = Mutex.new
|
|
331
|
+
@by_class = {}.freeze
|
|
332
|
+
@by_command = {}.freeze
|
|
333
|
+
@hooked = false
|
|
334
|
+
|
|
335
|
+
class << self
|
|
336
|
+
attr_writer :sources, :env, :root
|
|
337
|
+
|
|
338
|
+
# The sources read, in order. By default Solid Queue's recurring file
|
|
339
|
+
# when Solid Queue is loaded, and sidekiq-cron's schedule when
|
|
340
|
+
# sidekiq-cron is loaded and enabled.
|
|
341
|
+
def sources
|
|
342
|
+
return @sources if @sources
|
|
343
|
+
|
|
344
|
+
found = []
|
|
345
|
+
found << SolidQueue.new if defined?(::SolidQueue)
|
|
346
|
+
if defined?(::Sidekiq::Cron::Job)
|
|
347
|
+
config = ::Sidekiq::Cron.respond_to?(:configuration) && ::Sidekiq::Cron.configuration
|
|
348
|
+
found << SidekiqCron.new unless config.respond_to?(:enabled) && config.enabled == false
|
|
349
|
+
end
|
|
350
|
+
found
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
# The environment whose section of a Solid Queue file is read: Rails.env,
|
|
354
|
+
# RAILS_ENV or RACK_ENV (Cronwatch::Environment), else development.
|
|
355
|
+
def env
|
|
356
|
+
@env || Environment.name || "development"
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
# Relative paths are read from here: Rails.root, or the working directory.
|
|
360
|
+
def root
|
|
361
|
+
return @root.to_s if @root
|
|
362
|
+
return ::Rails.root.to_s if defined?(::Rails) && ::Rails.respond_to?(:root) && ::Rails.root
|
|
363
|
+
|
|
364
|
+
Dir.pwd
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
# Every entry of every source.
|
|
368
|
+
def entries
|
|
369
|
+
sources.flat_map(&:entries)
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
# { schedule:, timezone: } for the class's one entry in the scheduler's
|
|
373
|
+
# config. Raises Error when it has none, or more than one.
|
|
374
|
+
def schedule_for(klass)
|
|
375
|
+
name = klass.is_a?(Module) ? klass.name : klass.to_s
|
|
376
|
+
found = sources
|
|
377
|
+
if found.empty?
|
|
378
|
+
raise Error, "cronwatch: #{name} uses schedule: :from_scheduler, but neither Solid Queue nor sidekiq-cron is loaded; " \
|
|
379
|
+
"set Cronwatch::Scheduler.sources to say where the schedule is"
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
matches = found.flat_map(&:entries).select { |entry| !entry.disabled && same_class?(entry.class_name, name) }
|
|
383
|
+
if matches.empty?
|
|
384
|
+
raise Error, "cronwatch: #{name} uses schedule: :from_scheduler, but no enabled entry in " \
|
|
385
|
+
"#{found.map(&:label).join(" or ")} has class #{name}"
|
|
386
|
+
end
|
|
387
|
+
if matches.length > 1
|
|
388
|
+
raise Error, "cronwatch: #{name} uses schedule: :from_scheduler, but it is scheduled #{matches.length} times " \
|
|
389
|
+
"(#{matches.map(&:label).join(", ")}); a job has one schedule, so give cronwatch a schedule: of its own"
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
matches.first.convert
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
# Declares a job for every enabled entry once the app has booted, so a
|
|
396
|
+
# check reports one that never runs: a class that calls `cronwatch`
|
|
397
|
+
# declares itself, any other class is named as `cronwatch` would name
|
|
398
|
+
# it and its runs are recorded, and a Solid Queue `command:` is named
|
|
399
|
+
# after its key. Takes the options of Cronwatch::Client#job other than
|
|
400
|
+
# schedule and timezone (grace, timeout, failures_before_alert, tags,
|
|
401
|
+
# ...), for every job it declares, and except: keys to leave out.
|
|
402
|
+
def declare_from_scheduler!(except: [], **options)
|
|
403
|
+
bad = options.keys & %i[schedule timezone name]
|
|
404
|
+
raise ArgumentError, "cronwatch: declare_from_scheduler! takes #{bad.join(" and ")} from the scheduler" if bad.any?
|
|
405
|
+
|
|
406
|
+
@lock.synchronize { @pending = { except: Array(except).map(&:to_s), options: options.freeze }.freeze }
|
|
407
|
+
declare_pending! if Monitored.ready?
|
|
408
|
+
nil
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
# Declares what declare_from_scheduler! asked for. Called once the app
|
|
412
|
+
# has booted; raises Error for an entry that cannot be declared.
|
|
413
|
+
def declare_pending!
|
|
414
|
+
pending = @lock.synchronize { @pending }
|
|
415
|
+
return nil unless pending
|
|
416
|
+
|
|
417
|
+
by_class = {}
|
|
418
|
+
by_command = {}
|
|
419
|
+
by_name = {}
|
|
420
|
+
entries.each do |entry|
|
|
421
|
+
next if entry.disabled || pending[:except].include?(entry.key)
|
|
422
|
+
|
|
423
|
+
name, target = declared_name(entry)
|
|
424
|
+
next unless name
|
|
425
|
+
|
|
426
|
+
if (other = by_name[name])
|
|
427
|
+
raise Error, "cronwatch: #{entry.label} and #{other.where} would both be the job #{name.inspect}; " \
|
|
428
|
+
"leave one out with declare_from_scheduler!(except: [#{entry.key.inspect}])"
|
|
429
|
+
end
|
|
430
|
+
|
|
431
|
+
options = pending[:options].merge(entry.convert)
|
|
432
|
+
options[:description] ||= entry.description if entry.description
|
|
433
|
+
declaration = Monitored::Declaration.new(name, options, where: entry.label)
|
|
434
|
+
by_name[name] = declaration
|
|
435
|
+
target == :command ? by_command[entry.command] = declaration : by_class[entry.class_name.strip.delete_prefix("::")] = declaration
|
|
436
|
+
end
|
|
437
|
+
by_name.each_value { |declaration| declaration.registration(strict: true) }
|
|
438
|
+
@lock.synchronize do
|
|
439
|
+
@by_class = by_class.freeze
|
|
440
|
+
@by_command = by_command.freeze
|
|
441
|
+
end
|
|
442
|
+
install_active_job_hook if (by_class.any? || by_command.any?) && defined?(::ActiveJob::Base)
|
|
443
|
+
nil
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
# Declares the jobs declare_from_scheduler! declared on the current
|
|
447
|
+
# Cronwatch.client, as Monitored.register_all does for classes.
|
|
448
|
+
def register_declared(strict: true)
|
|
449
|
+
declarations = @lock.synchronize { (@by_class.values + @by_command.values).uniq }
|
|
450
|
+
declarations.each { |declaration| declaration.registration(strict: strict) }
|
|
451
|
+
nil
|
|
452
|
+
end
|
|
453
|
+
|
|
454
|
+
# The declaration for a class declare_from_scheduler! declared, if any.
|
|
455
|
+
def declaration_for_class(name)
|
|
456
|
+
@by_class[name.to_s]
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
# The declaration an ActiveJob perform runs as, if declare_from_scheduler!
|
|
460
|
+
# declared it: its class, or a Solid Queue command by its text. Nil for
|
|
461
|
+
# a class that calls `cronwatch`, which records itself.
|
|
462
|
+
def declaration_for_active_job(job)
|
|
463
|
+
klass = job.class
|
|
464
|
+
return nil if klass.respond_to?(:cronwatch_declaration) && klass.cronwatch_declaration
|
|
465
|
+
return @by_command[job.arguments.first.to_s] if command_job?(klass) && @by_command.any?
|
|
466
|
+
|
|
467
|
+
@by_class[klass.name.to_s]
|
|
468
|
+
end
|
|
469
|
+
|
|
470
|
+
# Forgets declare_from_scheduler! and what it declared. For tests.
|
|
471
|
+
def reset!
|
|
472
|
+
@lock.synchronize do
|
|
473
|
+
@pending = nil
|
|
474
|
+
@by_class = {}.freeze
|
|
475
|
+
@by_command = {}.freeze
|
|
476
|
+
end
|
|
477
|
+
nil
|
|
478
|
+
end
|
|
479
|
+
|
|
480
|
+
# The IANA name of the zone Fugit reads a schedule without one in (TZ,
|
|
481
|
+
# then Rails' Time.zone, then the system's), for the entry's error.
|
|
482
|
+
def local_zone_name(entry, scheduler)
|
|
483
|
+
zone = ::EtOrbi.determine_local_tzone
|
|
484
|
+
name = zone.respond_to?(:identifier) ? zone.identifier : zone&.name
|
|
485
|
+
return name if name.is_a?(String) && Zone.valid?(name)
|
|
486
|
+
|
|
487
|
+
raise Error, "cronwatch: #{entry.label}: #{entry.schedule.to_s.inspect} has no timezone, and #{scheduler} reads it in " \
|
|
488
|
+
"the process's zone, which is not an IANA timezone (#{name.inspect}); add one to the schedule, " \
|
|
489
|
+
"as in #{"#{entry.schedule} UTC".inspect}"
|
|
490
|
+
end
|
|
491
|
+
|
|
492
|
+
# The cron expression CronWatch reads for a Fugit::Cron in `zone`, as
|
|
493
|
+
# { schedule:, timezone: }. Raises Error for anything CronWatch would
|
|
494
|
+
# not read the same way: a form croner has no equivalent for, a zone
|
|
495
|
+
# that is not an IANA name, or a time that daylight saving skips.
|
|
496
|
+
def to_cronwatch(entry, cron, zone, scheduler)
|
|
497
|
+
where = "cronwatch: #{entry.label}: #{entry.schedule.to_s.inspect}"
|
|
498
|
+
unless Zone.valid?(zone)
|
|
499
|
+
raise Error, "#{where} is read in #{zone.inspect}, which is not an IANA timezone; name one, such as UTC or Europe/London"
|
|
500
|
+
end
|
|
501
|
+
raise Error, "#{where} picks a random time (~), which #{scheduler} and CronWatch would not pick alike" if cron.original.to_s.include?("~")
|
|
502
|
+
|
|
503
|
+
text = cron_text(cron, where)
|
|
504
|
+
parsed = begin
|
|
505
|
+
Schedule.parse(text, zone)
|
|
506
|
+
rescue ArgumentError => e
|
|
507
|
+
raise Error, "#{where} is #{text.inspect}, which CronWatch cannot read: #{e.message}"
|
|
508
|
+
end
|
|
509
|
+
check_fires(cron, parsed, where, scheduler)
|
|
510
|
+
{ schedule: text, timezone: zone }
|
|
511
|
+
end
|
|
512
|
+
|
|
513
|
+
private
|
|
514
|
+
|
|
515
|
+
def same_class?(written, name)
|
|
516
|
+
!written.nil? && written.to_s.strip.delete_prefix("::") == name.to_s
|
|
517
|
+
end
|
|
518
|
+
|
|
519
|
+
# Solid Queue runs a `command:` task as RecurringTask.default_job_class
|
|
520
|
+
# (SolidQueue::RecurringJob), with the command as its one argument.
|
|
521
|
+
def command_job?(klass)
|
|
522
|
+
task = defined?(::SolidQueue::RecurringTask) && ::SolidQueue::RecurringTask
|
|
523
|
+
default = task.respond_to?(:default_job_class) && task.default_job_class
|
|
524
|
+
default ? klass <= default : klass.name == "SolidQueue::RecurringJob"
|
|
525
|
+
end
|
|
526
|
+
|
|
527
|
+
# [name, :class or :command] for an entry, or nil for one left alone.
|
|
528
|
+
def declared_name(entry)
|
|
529
|
+
if entry.class_name
|
|
530
|
+
class_name = entry.class_name.strip.delete_prefix("::")
|
|
531
|
+
return nil if %w[Cronwatch::CheckJob Cronwatch::Sidekiq::CheckWorker].include?(class_name)
|
|
532
|
+
|
|
533
|
+
klass = begin
|
|
534
|
+
Object.const_get(class_name)
|
|
535
|
+
rescue NameError
|
|
536
|
+
raise Error, "cronwatch: #{entry.label} names the class #{class_name}, which does not load"
|
|
537
|
+
end
|
|
538
|
+
return nil if klass.respond_to?(:cronwatch_declaration) && klass.cronwatch_declaration
|
|
539
|
+
|
|
540
|
+
[Monitored.default_name(klass), :class]
|
|
541
|
+
elsif entry.command
|
|
542
|
+
unless Client::NAME_RE.match?(entry.key)
|
|
543
|
+
raise Error, "cronwatch: #{entry.label}: the key #{entry.key.inspect} cannot be a job name; " \
|
|
544
|
+
"use letters, digits, \".\", \"_\", \":\" or \"-\", or leave it out with except:"
|
|
545
|
+
end
|
|
546
|
+
|
|
547
|
+
[entry.key, :command]
|
|
548
|
+
else
|
|
549
|
+
raise Error, "cronwatch: #{entry.label} has neither a class nor a command to watch; leave it out with except:"
|
|
550
|
+
end
|
|
551
|
+
end
|
|
552
|
+
|
|
553
|
+
def install_active_job_hook
|
|
554
|
+
@lock.synchronize do
|
|
555
|
+
return if @hooked
|
|
556
|
+
|
|
557
|
+
@hooked = true
|
|
558
|
+
end
|
|
559
|
+
::ActiveJob::Base.around_perform do |job, block|
|
|
560
|
+
declaration = Cronwatch::Scheduler.declaration_for_active_job(job)
|
|
561
|
+
if declaration
|
|
562
|
+
Cronwatch::Monitored.record(declaration, "active_job", job) { block.call }
|
|
563
|
+
else
|
|
564
|
+
block.call
|
|
565
|
+
end
|
|
566
|
+
end
|
|
567
|
+
end
|
|
568
|
+
|
|
569
|
+
# The five or six fields croner reads for a Fugit::Cron.
|
|
570
|
+
def cron_text(cron, where)
|
|
571
|
+
fields = [list(cron.minutes), list(cron.hours), monthdays(cron.monthdays, where), list(cron.months),
|
|
572
|
+
weekdays(cron.weekdays, where)]
|
|
573
|
+
day_and = cron.instance_variable_get(:@day_and)
|
|
574
|
+
fields[4] = "+#{fields[4]}" if day_and && cron.monthdays && cron.weekdays
|
|
575
|
+
fields.unshift(list(cron.seconds)) unless cron.seconds == [0]
|
|
576
|
+
fields.join(" ")
|
|
577
|
+
end
|
|
578
|
+
|
|
579
|
+
def list(values)
|
|
580
|
+
values.nil? ? "*" : values.join(",")
|
|
581
|
+
end
|
|
582
|
+
|
|
583
|
+
def monthdays(values, where)
|
|
584
|
+
return "*" if values.nil?
|
|
585
|
+
|
|
586
|
+
values.map do |day|
|
|
587
|
+
next day.to_s if day.positive?
|
|
588
|
+
next "L" if day == -1
|
|
589
|
+
|
|
590
|
+
raise Error, "#{where} counts days back from the end of the month (#{day}), which CronWatch cannot read; only the last day (L) is"
|
|
591
|
+
end.join(",")
|
|
592
|
+
end
|
|
593
|
+
|
|
594
|
+
def weekdays(values, where)
|
|
595
|
+
return "*" if values.nil?
|
|
596
|
+
|
|
597
|
+
values.map do |day, nth|
|
|
598
|
+
if nth.nil? then day.to_s
|
|
599
|
+
elsif nth == -1 then "#{day}L"
|
|
600
|
+
elsif nth.is_a?(Integer) && nth.between?(1, 5) then "#{day}##{nth}"
|
|
601
|
+
elsif nth.is_a?(Array)
|
|
602
|
+
raise Error, "#{where} fires every #{nth[0]} weeks (%), which CronWatch cannot read"
|
|
603
|
+
else
|
|
604
|
+
raise Error, "#{where} counts weekdays back from the end of the month (##{nth}), which CronWatch cannot read"
|
|
605
|
+
end
|
|
606
|
+
end.join(",")
|
|
607
|
+
end
|
|
608
|
+
|
|
609
|
+
# Checks that CronWatch expects runs when the scheduler makes them, by
|
|
610
|
+
# walking Fugit's runs and CronWatch's fires side by side: from two days
|
|
611
|
+
# before to two days after every clock change in the next HORIZON_YEARS
|
|
612
|
+
# years (one change of each kind for a cron that names no day or month,
|
|
613
|
+
# which meets every such change alike), and from the start of each month
|
|
614
|
+
# of a sample year, so the answer does not depend on when the app boots.
|
|
615
|
+
#
|
|
616
|
+
# Between two runs of the scheduler, CronWatch must not want one of its
|
|
617
|
+
# own, or it would report it missed: a fire CronWatch has and the
|
|
618
|
+
# scheduler does not (a time Fugit skips when clocks go forward, or a
|
|
619
|
+
# run its hour steps drop on that day) is refused unless the run before
|
|
620
|
+
# it covers it (a minute of early slack, as in a burst such as
|
|
621
|
+
# "* 5 * * *", or a fire moved past a spring-forward jump). Away from
|
|
622
|
+
# clock changes every run the scheduler makes must also be one CronWatch
|
|
623
|
+
# expects; near one, Fugit may run a repeated time twice, which
|
|
624
|
+
# CronWatch takes as an early run.
|
|
625
|
+
def check_fires(cron, parsed, where, scheduler)
|
|
626
|
+
zone = parsed.timezone
|
|
627
|
+
tz = Zone.get(zone)
|
|
628
|
+
cron = in_zone(cron, zone)
|
|
629
|
+
|
|
630
|
+
daily = cron.monthdays.nil? && cron.months.nil? && cron.weekdays.nil?
|
|
631
|
+
seen = {}
|
|
632
|
+
now = Time.now.utc
|
|
633
|
+
tz.transitions_up_to(Time.utc(now.year + HORIZON_YEARS + 1, 1, 1), Time.utc(now.year, 1, 1)).each do |change|
|
|
634
|
+
before = change.previous_offset.utc_total_offset
|
|
635
|
+
kind = [(change.at.to_i + before) % 86_400, change.offset.utc_total_offset - before]
|
|
636
|
+
next if daily && seen[kind]
|
|
637
|
+
|
|
638
|
+
seen[kind] = true
|
|
639
|
+
from = (change.at.to_i * 1000) - CHANGE_WINDOW_MS
|
|
640
|
+
to = from + (2 * CHANGE_WINDOW_MS)
|
|
641
|
+
# Near a change only CronWatch's own fires can be refused, so a
|
|
642
|
+
# stretch where it has none needs no walk (Fugit's is slow for L and #).
|
|
643
|
+
first = Schedule.next_runs(parsed, 1, from - 1).first
|
|
644
|
+
next if first.nil? || first > to
|
|
645
|
+
|
|
646
|
+
compare_runs(fugit_runs(cron, from, to), parsed, false, where, scheduler)
|
|
647
|
+
end
|
|
648
|
+
|
|
649
|
+
compared_until = nil
|
|
650
|
+
SAMPLE_MONTHS.times do |month|
|
|
651
|
+
from = Time.utc(SAMPLE_YEAR, month + 1, 1).to_i * 1000
|
|
652
|
+
next if compared_until && from < compared_until # a sparse cron's earlier sample reached past this month
|
|
653
|
+
|
|
654
|
+
runs = fugit_runs(cron, from, nil)
|
|
655
|
+
compared_until = runs.last
|
|
656
|
+
near = tz.transitions_up_to(Time.at((runs.last / 1000) + 86_400).utc, Time.at((runs.first / 1000) - 86_400).utc).any?
|
|
657
|
+
compare_runs(runs, parsed, !near, where, scheduler)
|
|
658
|
+
end
|
|
659
|
+
rescue TZInfo::AmbiguousTime, TZInfo::PeriodNotFound => e
|
|
660
|
+
raise Error, "#{where} cannot be checked in #{zone}: Fugit fails on a time around a clock change there " \
|
|
661
|
+
"(#{e.class}: #{e.message}); give cronwatch a schedule: of its own"
|
|
662
|
+
rescue RuntimeError => e
|
|
663
|
+
raise Error, "#{where} never fires: #{e.message}"
|
|
664
|
+
end
|
|
665
|
+
|
|
666
|
+
# Fugit's runs: the one before `from` and every one after it up to the
|
|
667
|
+
# first past `to`, or SAMPLE_RUNS of them from `from` when `to` is nil.
|
|
668
|
+
def fugit_runs(cron, from, to)
|
|
669
|
+
runs = [fugit_ms(cron.previous_time(Time.at(from / 1000).utc))]
|
|
670
|
+
loop do
|
|
671
|
+
runs << fugit_ms(cron.next_time(Time.at(runs.last / 1000).utc))
|
|
672
|
+
break if to ? runs.last > to : runs.length > SAMPLE_RUNS
|
|
673
|
+
end
|
|
674
|
+
runs
|
|
675
|
+
end
|
|
676
|
+
|
|
677
|
+
# CronWatch's fires after `from`, up to and including `to`.
|
|
678
|
+
def cronwatch_fires(parsed, from, to)
|
|
679
|
+
fires = []
|
|
680
|
+
at = from
|
|
681
|
+
loop do
|
|
682
|
+
fire = Schedule.next_runs(parsed, 1, at).first
|
|
683
|
+
# Asked from inside a repeated hour the walker can answer with a past time.
|
|
684
|
+
fire = Schedule.fire_after(parsed, at) if fire && fire <= at
|
|
685
|
+
break if fire.nil? || fire > to
|
|
686
|
+
|
|
687
|
+
fires << fire
|
|
688
|
+
at = fire
|
|
689
|
+
end
|
|
690
|
+
fires
|
|
691
|
+
end
|
|
692
|
+
|
|
693
|
+
# Refuses the conversion where, after one of the scheduler's runs,
|
|
694
|
+
# CronWatch would want a run before the scheduler's next (or, when
|
|
695
|
+
# `strict`, where the scheduler's next is not a time CronWatch fires).
|
|
696
|
+
def compare_runs(runs, parsed, strict, where, scheduler)
|
|
697
|
+
fires = cronwatch_fires(parsed, runs.first, runs.last)
|
|
698
|
+
expected = strict ? fires.to_h { |fire| [fire, true] } : nil
|
|
699
|
+
i = 0
|
|
700
|
+
runs.each_cons(2) do |at, following|
|
|
701
|
+
i += 1 while i < fires.length && fires[i] <= at
|
|
702
|
+
own = i >= fires.length || fires[i] < following
|
|
703
|
+
unexpected = strict && !expected[following]
|
|
704
|
+
next unless own || unexpected
|
|
705
|
+
|
|
706
|
+
due = Schedule.due_after_run(parsed, at)
|
|
707
|
+
next if !unexpected && due && due >= following
|
|
708
|
+
|
|
709
|
+
mismatch(parsed, at, following, due, where, scheduler)
|
|
710
|
+
end
|
|
711
|
+
end
|
|
712
|
+
|
|
713
|
+
def mismatch(parsed, at, following, due, where, scheduler)
|
|
714
|
+
zone = parsed.timezone
|
|
715
|
+
tz = Zone.get(zone)
|
|
716
|
+
skipped = due && tz.transitions_up_to(Time.at((due / 1000) + 1).utc, Time.at((due / 1000) - 86_400).utc).find do |change|
|
|
717
|
+
gap = change.offset.utc_total_offset - change.previous_offset.utc_total_offset
|
|
718
|
+
gap.positive? && due < (change.at.to_i + gap) * 1000
|
|
719
|
+
end
|
|
720
|
+
unless skipped
|
|
721
|
+
raise Error, "#{where} is #{parsed.source.inspect} in #{zone}, but after a run at #{stamp(at, zone)} #{scheduler} " \
|
|
722
|
+
"runs it next at #{stamp(following, zone)} and CronWatch would expect #{due ? stamp(due, zone) : "nothing"}, " \
|
|
723
|
+
"so it cannot be converted exactly; give cronwatch a schedule: of its own"
|
|
724
|
+
end
|
|
725
|
+
|
|
726
|
+
before = skipped.previous_offset.utc_total_offset
|
|
727
|
+
old = Time.at(skipped.at.to_i + before).utc
|
|
728
|
+
new = Time.at(skipped.at.to_i + skipped.offset.utc_total_offset).utc
|
|
729
|
+
raise Error, "#{where} is due at a time that does not exist in #{zone} on #{old.strftime("%Y-%m-%d")}, when clocks " \
|
|
730
|
+
"go forward from #{old.strftime("%H:%M")} to #{new.strftime("%H:%M")}. " \
|
|
731
|
+
"#{scheduler} skips that run and CronWatch would expect it at #{stamp(due, zone)}, so it would be " \
|
|
732
|
+
"reported missed. Move the time outside the change, give the schedule a zone without daylight " \
|
|
733
|
+
"saving (such as UTC), or give cronwatch a schedule: of its own"
|
|
734
|
+
end
|
|
735
|
+
|
|
736
|
+
# The cron with its zone named, so it is read in `zone` whatever this
|
|
737
|
+
# process's local zone is later.
|
|
738
|
+
def in_zone(cron, zone)
|
|
739
|
+
return cron if cron.timezone
|
|
740
|
+
|
|
741
|
+
Fugit::Cron.parse("#{cron.to_cron_s} #{zone}") || cron
|
|
742
|
+
end
|
|
743
|
+
|
|
744
|
+
def fugit_ms(time)
|
|
745
|
+
time.to_i * 1000
|
|
746
|
+
end
|
|
747
|
+
|
|
748
|
+
def stamp(ms, zone)
|
|
749
|
+
wall = Zone.wall(ms / 1000, zone)
|
|
750
|
+
format("%04d-%02d-%02d %02d:%02d:%02d", *wall)
|
|
751
|
+
end
|
|
752
|
+
end
|
|
753
|
+
end
|
|
754
|
+
|
|
755
|
+
class << self
|
|
756
|
+
# Declares a job for every entry in the scheduler's config (Solid Queue's
|
|
757
|
+
# config/recurring.yml, sidekiq-cron's schedule), once the app has
|
|
758
|
+
# booted. See Cronwatch::Scheduler.declare_from_scheduler!.
|
|
759
|
+
def declare_from_scheduler!(**options)
|
|
760
|
+
Scheduler.declare_from_scheduler!(**options)
|
|
761
|
+
end
|
|
762
|
+
end
|
|
763
|
+
end
|