Class: DevDoc::Test::Lints::CronScheduleChecker

Inherits:
Object
  • Object
show all
Defined in:
lib/dev_doc/test/lints/cron_schedule.rb

Overview

Framework-agnostic check: reads a sidekiq-cron-style ‘schedules.yml` and returns a list of offender descriptions for any cron expression that fires less often than `max_interval_seconds`, and optionally checks that each job’s staleness threshold is greater than the cron interval.

Wrapped by the Minitest module ‘CronSchedule` below — see that module for the rationale and examples. Tests cover the checker directly so they don’t have to mock a test framework.

Constant Summary collapse

DEFAULT_MAX_INTERVAL_SECONDS =

1 day. Weekly (and longer) crons are always wrong: no staleness threshold can usefully exceed the cron interval, so a weekly cron can’t be paired with a meaningful self-throttle. Daily cron is the most generous interval the pattern supports (works for weekly-desired refreshes like Myki). Hourly+ is always safe.

86_400
MissingFile =

Sentinel returned by ‘#offenders` when the schedules file doesn’t exist. Distinguishable from ‘[]` (file present, all good) so the Minitest wrapper can `skip` rather than `assert`.

Module.new
DEFAULT_STALENESS_CONSTANT =

Default constant name to look up in job source files when checking that the staleness threshold is larger than the cron interval.

'STALENESS_THRESHOLD'.freeze
SAMPLE_ANCHOR =

Anchored reference for sampling cron fires. A fixed Monday so weekday-restricted crons behave the same regardless of when the test suite runs.

Time.utc(2025, 1, 6).freeze
SAMPLE_COUNT =

How many consecutive fires to sample. 100 covers:

- sub-hourly crons (max gap surfaces within minutes)
- business-hours patterns like `0 9-17 * * *` (overnight gap
  visible after the first day)
- weekly/monthly (max gap visible within a few cycles)
- yearly (still gets ≥2 fires)
100

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(schedules_path, max_interval_seconds: DEFAULT_MAX_INTERVAL_SECONDS) ⇒ CronScheduleChecker

Returns a new instance of CronScheduleChecker.



33
34
35
36
# File 'lib/dev_doc/test/lints/cron_schedule.rb', line 33

def initialize(schedules_path, max_interval_seconds: DEFAULT_MAX_INTERVAL_SECONDS)
  @schedules_path = schedules_path
  @max_interval_seconds = max_interval_seconds
end

Class Method Details

.format_seconds(seconds) ⇒ Object



105
106
107
108
109
110
111
# File 'lib/dev_doc/test/lints/cron_schedule.rb', line 105

def self.format_seconds(seconds)
  return "#{seconds}s" if seconds < 60
  return "#{seconds / 60}m" if seconds < 3600
  return "#{seconds / 3600}h" if seconds < 86_400

  "#{seconds / 86_400}d"
end

Instance Method Details

#offendersObject

Returns an Array<String> of offender descriptions (one per invalid or too-infrequent cron), ‘[]` if all crons are compliant, or `MissingFile` if the schedules file doesn’t exist.



42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
# File 'lib/dev_doc/test/lints/cron_schedule.rb', line 42

def offenders
  return MissingFile unless @schedules_path.exist?

  # YAML.safe_load returns nil for an empty file.
  (YAML.safe_load(@schedules_path.read) || {}).each_with_object([]) do |(name, opts), acc|
    next unless opts.is_a?(Hash) && (cron = opts['cron'])

    interval = interval_seconds_for(cron)
    if interval == :invalid
      acc << "  #{name}: invalid cron expression (cron: #{cron.inspect})"
      next
    end
    next if interval.nil? || interval <= @max_interval_seconds

    acc << "  #{name}: every #{self.class.format_seconds(interval)} (cron: #{cron.inspect})"
  end
end

#staleness_offenders(jobs_path: nil, constant: DEFAULT_STALENESS_CONSTANT) ⇒ Object

Checks that each job’s staleness threshold constant is strictly greater than the cron interval. Returns an Array<String> of offender descriptions, ‘[]` if all are compliant, or `MissingFile` if the schedules file doesn’t exist.

Parameters:

jobs_path   - Pathname/String pointing to `app/jobs/` (or any
              directory tree to search for job source files).
              If nil or the path doesn't exist, returns `[]`
              (nothing to check — silently skipped so projects
              without job-level constants don't have to opt out).
constant    - Name of the constant to look up (default:
              `STALENESS_THRESHOLD`). Projects that use a
              different convention (e.g. `STALE_AFTER`) can
              override this.

The constant value is extracted by grepping the job source files for the pattern ‘CONSTANT = <number>.<unit>` (e.g. `1.hour`, `45.minutes`). Jobs that use a per-record stale? method instead of a job-level constant produce no constant to inspect and are skipped silently.



80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
# File 'lib/dev_doc/test/lints/cron_schedule.rb', line 80

def staleness_offenders(jobs_path: nil, constant: DEFAULT_STALENESS_CONSTANT)
  return MissingFile unless @schedules_path.exist?
  return [] if jobs_path.nil?

  jobs_root = Pathname.new(jobs_path)
  return [] unless jobs_root.exist?

  (YAML.safe_load(@schedules_path.read) || {}).each_with_object([]) do |(name, opts), acc|
    next unless opts.is_a?(Hash) && (cron = opts['cron']) && (class_name = opts['class'])

    interval = interval_seconds_for(cron)
    next if interval == :invalid || interval.nil?

    threshold = staleness_threshold_for(class_name, jobs_root, constant)
    next if threshold.nil? # no constant found — skip silently

    next if interval < threshold

    acc << "  #{name} (#{class_name}): cron fires every " \
           "#{self.class.format_seconds(interval)} but #{constant} is " \
           "#{self.class.format_seconds(threshold)}" \
           'cron interval must be strictly less than the staleness threshold'
  end
end