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.
Files changed (46) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +268 -0
  4. data/lib/cronwatch/abort_signal.rb +45 -0
  5. data/lib/cronwatch/active_record.rb +11 -0
  6. data/lib/cronwatch/alerts/console.rb +22 -0
  7. data/lib/cronwatch/alerts/custom.rb +26 -0
  8. data/lib/cronwatch/alerts/discord.rb +52 -0
  9. data/lib/cronwatch/alerts/slack.rb +58 -0
  10. data/lib/cronwatch/alerts/webhook.rb +46 -0
  11. data/lib/cronwatch/client.rb +925 -0
  12. data/lib/cronwatch/cron_pattern.rb +277 -0
  13. data/lib/cronwatch/duration.rb +77 -0
  14. data/lib/cronwatch/environment.rb +29 -0
  15. data/lib/cronwatch/evaluate.rb +345 -0
  16. data/lib/cronwatch/flight.rb +42 -0
  17. data/lib/cronwatch/format.rb +89 -0
  18. data/lib/cronwatch/http.rb +52 -0
  19. data/lib/cronwatch/job.rb +144 -0
  20. data/lib/cronwatch/js.rb +188 -0
  21. data/lib/cronwatch/monitored.rb +259 -0
  22. data/lib/cronwatch/output.rb +199 -0
  23. data/lib/cronwatch/rails/active_job.rb +55 -0
  24. data/lib/cronwatch/rails/check_job.rb +32 -0
  25. data/lib/cronwatch/rails/railtie.rb +37 -0
  26. data/lib/cronwatch/rails/tasks.rb +12 -0
  27. data/lib/cronwatch/rails.rb +35 -0
  28. data/lib/cronwatch/schedule.rb +191 -0
  29. data/lib/cronwatch/scheduler.rb +763 -0
  30. data/lib/cronwatch/serialize.rb +51 -0
  31. data/lib/cronwatch/sidekiq.rb +129 -0
  32. data/lib/cronwatch/stats.rb +23 -0
  33. data/lib/cronwatch/stores/active_record.rb +397 -0
  34. data/lib/cronwatch/stores/memory.rb +163 -0
  35. data/lib/cronwatch/ticker.rb +59 -0
  36. data/lib/cronwatch/triage/anthropic.rb +134 -0
  37. data/lib/cronwatch/types.rb +341 -0
  38. data/lib/cronwatch/version.rb +6 -0
  39. data/lib/cronwatch/walker.rb +137 -0
  40. data/lib/cronwatch/web/app.rb +484 -0
  41. data/lib/cronwatch/web/html.rb +314 -0
  42. data/lib/cronwatch/web.rb +17 -0
  43. data/lib/cronwatch/zone.rb +72 -0
  44. data/lib/cronwatch.rb +96 -0
  45. data/lib/generators/cronwatch/install/install_generator.rb +176 -0
  46. 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