que-scheduler 7.2.0 → 9.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bdd3f8eb6dccc6c82cb4ce97f0a64f7acf30394cdc4d7f7f299d86680209777a
4
- data.tar.gz: ac8c0c66d2fb3def5887a6f7cc27b6577c28692b55549fd067e09ad6767fe68e
3
+ metadata.gz: 43c9761073fc43892c380b61ea7aeadb456c19b1f3d992eca44f93b7ec41c757
4
+ data.tar.gz: e7897e20ca62c7183f6db25289dc1781e825193238b1a0687eabe2ced6a3dcf5
5
5
  SHA512:
6
- metadata.gz: 384c9a75444bac91293d13f4bcfa9c79418df2b43d537a8707b1996705c27d44d44422a7af90135ec34311d872096fa06a1ac32eefbf5bf770223673019c2606
7
- data.tar.gz: 1f6e7fa846f34937010560a8905c84b56f2e67f87acafd9d0b72d83503f9ec32b36b7ad9c1d77f7396cd7e8ccfe0564a494d2992bb82430fc4ff3cfab76a6f46
6
+ metadata.gz: 3f5b2b777d5e52aff1d6422a84569b048db2b544e7b17457f1c413e5caa11d0505e90540a75264cd2fdecbdeb621a3f326f1591e5ff355169ecb1eba6b867d9d
7
+ data.tar.gz: 4b6ddb6b440fb761cecb48cdc815ecde9b1963535d2eb919b2f630ba899f54d6c21ace7c37a93258faa85c41f02164a0e52e1f8f5727a81e4148485b92811133
data/README.md CHANGED
@@ -5,7 +5,6 @@ que-scheduler
5
5
  [![specs workflow](https://github.com/hlascelles/que-scheduler/actions/workflows/specs.yml/badge.svg)](https://github.com/hlascelles/que-scheduler/actions)
6
6
  [![License](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
7
7
  [![Coverage Status](https://coveralls.io/repos/github/hlascelles/que-scheduler/badge.svg?branch=master)](https://coveralls.io/github/hlascelles/que-scheduler?branch=master)
8
- [![Code Climate Maintainability](https://api.codeclimate.com/v1/badges/710d2fc5202f95d76e8a/maintainability)](https://codeclimate.com/github/hlascelles/que-scheduler/maintainability)
9
8
 
10
9
  ## Description
11
10
 
@@ -317,9 +316,10 @@ A full changelog can be found here: [CHANGELOG.md](https://github.com/hlascelles
317
316
 
318
317
  ## System requirements
319
318
 
320
- Your [postgres](https://www.postgresql.org/) database must be at least version 9.6.0.
319
+ Your [postgres](https://www.postgresql.org/) database must be at least version 14. que-scheduler is tested against version 17.
321
320
 
322
- The latest version of que-scheduler supports Ruby 3.0 and above.
321
+ The latest version of que-scheduler supports Ruby 4.0 and above, and Rails 8.0 and above.
322
+ que-scheduler versions below 8.0.0 work with Ruby 3.1 and Rails 6.0.
323
323
  que-scheduler versions below 4.4.0 work with Ruby 2.7.
324
324
  que-scheduler versions below 4.2.3 work with Ruby 2.5 and Ruby 2.6.
325
325
 
@@ -32,7 +32,7 @@ module Que
32
32
  params(
33
33
  scheduler_job_id: Integer,
34
34
  executed_at: Types::TIME,
35
- enqueued_jobs: T::Array[Que::Scheduler::EnqueuedJobType]
35
+ enqueued_jobs: T::Array[Que::Scheduler::EnqueuedJob]
36
36
  ).void
37
37
  end
38
38
  def append(scheduler_job_id, executed_at, enqueued_jobs)
@@ -40,10 +40,11 @@ module Que
40
40
  enqueued_jobs.each do |j|
41
41
  inserted = ::Que::Scheduler::DbSupport.execute(
42
42
  INSERT_AUDIT_ENQUEUED,
43
- [scheduler_job_id] +
44
- j.values_at(:job_class, :queue, :priority, :args, :job_id, :run_at)
43
+ [scheduler_job_id, j.job_class, j.queue, j.priority, j.args, j.job_id, j.run_at]
45
44
  )
46
- raise "Cannot save audit row #{scheduler_job_id} #{executed_at} #{j}" if inserted.empty?
45
+ next unless inserted.empty?
46
+
47
+ raise "Cannot save audit row #{scheduler_job_id} #{executed_at} #{j.inspect}"
47
48
  end
48
49
  end
49
50
  end
@@ -1,6 +1,5 @@
1
1
  # typed: true
2
2
 
3
- require "pathname"
4
3
  require "que"
5
4
  require "sorbet-runtime"
6
5
  require_relative "db_support"
@@ -19,31 +19,24 @@ module Que
19
19
  sig { params(enqueued_job: Que::Job).returns(Integer) }
20
20
  def job_id(enqueued_job) = T.cast(job_attributes(enqueued_job).fetch(:job_id), Integer)
21
21
 
22
- # Between Que versions the result of Que execute changed keys from strings to symbols.
23
- # Here we wrap the concept and make sure either way produces symbols
24
22
  sig do
25
23
  params(str: String, args: T::Array[T.anything])
26
24
  .returns(T::Array[T::Hash[Symbol, T.anything]])
27
25
  end
28
26
  def execute(str, args = [])
29
- normalise_array_of_hashes(Que.execute(str, args))
27
+ T.cast(Que.execute(str, args), T::Array[T::Hash[Symbol, T.anything]])
30
28
  end
31
29
 
32
30
  sig do
33
31
  params(
34
32
  clazz: T.class_of(Que::Job),
35
33
  job_options: T::Hash[Symbol, T.anything],
36
- job_args: T.anything
34
+ job_args: T::Array[T.anything]
37
35
  ).returns(T.nilable(T.any(Que::Job, FalseClass)))
38
36
  end
39
37
  def enqueue_a_job(clazz, job_options = {}, job_args = [])
40
- case job_args
41
- when Hash
42
- clazz.enqueue(job_args, job_options: job_options)
43
- else
44
- # Sorbet cannot type a splat of a value of unknown size
45
- T.unsafe(clazz).enqueue(*job_args, job_options: job_options)
46
- end
38
+ # Sorbet cannot type a splat of an array of unknown size
39
+ T.unsafe(clazz).enqueue(*job_args, job_options: job_options)
47
40
  end
48
41
 
49
42
  sig { returns(String) }
@@ -55,11 +48,6 @@ module Que
55
48
  private def que_version_object
56
49
  @que_version_object ||= T.must(Gem.loaded_specs["que"]).version
57
50
  end
58
-
59
- sig { params(array: T::Array[T.anything]).returns(T::Array[T::Hash[Symbol, T.anything]]) }
60
- private def normalise_array_of_hashes(array)
61
- array.map { |row| T.cast(row, T::Hash[T.any(String, Symbol), T.anything]).to_h.transform_keys(&:to_sym) }
62
- end
63
51
  end
64
52
  end
65
53
  end
@@ -1,39 +1,25 @@
1
1
  # typed: true
2
2
 
3
- require "hashie"
4
3
  require "sorbet-runtime"
5
4
  require "fugit"
5
+ require_relative "schedule_type"
6
+ require_relative "to_enqueue"
6
7
  require_relative "types"
7
8
 
8
9
  # This is the definition of one scheduleable job in the que-scheduler config yml file.
9
10
  module Que
10
11
  module Scheduler
11
- class DefinedJob < Hashie::Dash # rubocop:disable Metrics/ClassLength -- shrinks with T::Struct
12
- include Hashie::Extensions::Dash::PropertyTranslation
12
+ class DefinedJob < T::Struct
13
13
  extend T::Sig
14
14
 
15
- DEFINED_JOB_TYPES = [
16
- DEFINED_JOB_TYPE_DEFAULT = :default,
17
- DEFINED_JOB_TYPE_EVERY_EVENT = :every_event,
18
- ].freeze
19
-
20
- property :name
21
- property :job_class, transform_with: ->(v) { Object.const_get(v) }
22
- property :cron, transform_with: ->(v) { Fugit::Cron.parse(v) }
23
- property :queue
24
- property :priority
25
- property :args_array
26
- property :schedule_type, default: DEFINED_JOB_TYPE_DEFAULT
27
-
28
- class << self
29
- extend T::Sig
30
-
31
- sig { params(options: T::Hash[Symbol, T.anything]).returns(DefinedJob) }
32
- def create(options)
33
- defined_job = new(options.compact)
34
- defined_job.freeze.tap { |dj| dj.validate(options) }
35
- end
36
- end
15
+ const :name, String
16
+ const :job_class, Class
17
+ const :cron, Fugit::Cron
18
+ # nil means use the job class's own queue and priority
19
+ const :queue, T.nilable(String)
20
+ const :priority, T.nilable(Integer)
21
+ const :args_array, T::Array[T.anything], default: []
22
+ const :schedule_type, ScheduleType, default: ScheduleType::DEFAULT
37
23
 
38
24
  # Given a "last time", return the next Time the event will occur, or nil if it
39
25
  # is after "to".
@@ -58,87 +44,24 @@ module Que
58
44
  generate_to_enqueue_list(missed_times)
59
45
  end
60
46
 
61
- sig { params(options: T::Hash[Symbol, T.anything]).void }
62
- def validate(options)
63
- validate_fields_presence(options)
64
- validate_fields_types(options)
65
- validate_job_class_related(options)
66
- end
67
-
68
- # rubocop:disable Style/GuardClause -- This reads better as a conditional
69
- sig { params(options: T::Hash[Symbol, T.anything]).void }
70
- private def validate_fields_types(options)
71
- unless queue.nil? || queue.is_a?(String)
72
- err_field(:queue, options, "queue must be a string")
73
- end
74
- unless priority.nil? || priority.is_a?(Integer)
75
- err_field(:priority, options, "priority must be an integer")
76
- end
77
- unless DEFINED_JOB_TYPES.include?(schedule_type)
78
- err_field(:schedule_type, options, "Not in #{DEFINED_JOB_TYPES}")
79
- end
80
- end
81
- # rubocop:enable Style/GuardClause
82
-
83
- sig { params(options: T::Hash[Symbol, T.anything]).void }
84
- private def validate_fields_presence(options)
85
- err_field(:name, options, "name must be present") if name.nil?
86
- err_field(:job_class, options, "job_class must be present") if job_class.nil?
87
- # An invalid cron is nil
88
- err_field(:cron, options, "cron must be present") if cron.nil?
89
- end
90
-
91
- sig { params(options: T::Hash[Symbol, T.anything]).void }
92
- private def validate_job_class_related(options)
93
- # Only support known job engines
94
- unless Que::Scheduler::ToEnqueue.valid_job_class?(job_class)
95
- err_field(:job_class, options, "Job #{job_class} was not a supported job type")
96
- end
97
-
98
- # queue name is only supported for a subrange of ActiveJob versions. Print this out as a
99
- # warning.
100
- if queue &&
101
- Que::Scheduler::ToEnqueue.active_job_defined? &&
102
- job_class < ::ActiveJob::Base &&
103
- T.must(Que::Scheduler::ToEnqueue.active_job_version) < Gem::Version.create("6.0.3")
104
- puts <<~ERR
105
- WARNING from que-scheduler....
106
- Between versions 4.2.3 and 6.0.2 (inclusive) Rails did not support setting queue names
107
- on que jobs with ActiveJob, so que-scheduler cannot support it.
108
- See removed in Rails 4.2.3
109
- https://github.com/rails/rails/pull/19498
110
- And readded in Rails 6.0.3
111
- https://github.com/rails/rails/pull/38635
112
-
113
- Please remove all "queue" keys from ActiveJobs defined in the que-scheduler.yml config.
114
- Specifically #{queue} for job #{name}.
115
- ERR
116
- end
117
- end
118
-
119
- sig { params(field: Symbol, options: T::Hash[Symbol, T.anything], reason: String).returns(T.noreturn) }
120
- private def err_field(field, options, reason = "")
121
- schedule = Que::Scheduler.configuration.schedule_location
122
- value = options[field]
123
- raise "Invalid #{field} '#{value}' for '#{name}' in que-scheduler schedule #{schedule}.\n" \
124
- "#{reason}"
125
- end
126
-
127
47
  sig { params(missed_times: T::Array[ActiveSupport::TimeWithZone]).returns(T::Array[ToEnqueue]) }
128
48
  private def generate_to_enqueue_list(missed_times)
129
- return [] if missed_times.empty?
49
+ last_missed_time = missed_times.last
50
+ return [] if last_missed_time.nil?
130
51
 
131
- options = to_h.slice(:args, :queue, :priority, :job_class).compact
132
-
133
- if schedule_type == DefinedJob::DEFINED_JOB_TYPE_EVERY_EVENT
134
- missed_times.map do |time_missed|
135
- ToEnqueue.create(options.merge(run_at: time_missed,
136
- args: [time_missed.iso8601] + args_array))
137
- end
52
+ if schedule_type == ScheduleType::EVERY_EVENT
53
+ missed_times.map { to_enqueue(it, [it.iso8601] + args_array) }
138
54
  else
139
- [ToEnqueue.create(options.merge(run_at: missed_times.last, args: args_array))]
55
+ [to_enqueue(last_missed_time, args_array)]
140
56
  end
141
57
  end
58
+
59
+ sig { params(run_at: ActiveSupport::TimeWithZone, args: T::Array[T.anything]).returns(ToEnqueue) }
60
+ private def to_enqueue(run_at, args)
61
+ ToEnqueue.new(
62
+ job_class: job_class, args: args, queue: queue, priority: priority, run_at: run_at
63
+ )
64
+ end
142
65
  end
143
66
  end
144
67
  end
@@ -0,0 +1,96 @@
1
+ # typed: true
2
+
3
+ require "fugit"
4
+ require "sorbet-runtime"
5
+ require_relative "defined_job"
6
+ require_relative "job_adapters"
7
+ require_relative "schedule_type"
8
+
9
+ # Turns one job's entry in the schedule config into a validated DefinedJob.
10
+ module Que
11
+ module Scheduler
12
+ module DefinedJobParser
13
+ # One job's entry in the schedule, with string keys
14
+ CONFIG = T.type_alias { T::Hash[String, Object] }
15
+
16
+ class << self
17
+ extend T::Sig
18
+
19
+ sig { params(name: String, config: CONFIG).returns(DefinedJob) }
20
+ def parse(name, config)
21
+ DefinedJob.new(
22
+ name: name,
23
+ job_class: parse_job_class(name, config),
24
+ cron: parse_cron(name, config),
25
+ queue: parse_queue(name, config),
26
+ priority: parse_priority(name, config),
27
+ args_array: parse_args(config),
28
+ schedule_type: parse_schedule_type(name, config)
29
+ )
30
+ end
31
+
32
+ sig { params(name: String, config: CONFIG).returns(Class) }
33
+ private def parse_job_class(name, config)
34
+ class_name = String(config.fetch("class", name))
35
+ job_class = Object.const_get(class_name)
36
+ return job_class if job_class.is_a?(Class) && JobAdapters.supports?(job_class)
37
+
38
+ reason = "Job #{class_name} was not a supported job type"
39
+ raise_invalid(name, :job_class, class_name, reason)
40
+ end
41
+
42
+ sig { params(name: String, config: CONFIG).returns(Fugit::Cron) }
43
+ private def parse_cron(name, config)
44
+ value = config["cron"]
45
+ # An invalid cron parses to nil
46
+ Fugit::Cron.parse(value) || raise_invalid(name, :cron, value, "cron must be present")
47
+ end
48
+
49
+ sig { params(name: String, config: CONFIG).returns(T.nilable(String)) }
50
+ private def parse_queue(name, config)
51
+ value = config["queue"]
52
+ return value if value.nil? || value.is_a?(String)
53
+
54
+ raise_invalid(name, :queue, value, "queue must be a string")
55
+ end
56
+
57
+ sig { params(name: String, config: CONFIG).returns(T.nilable(Integer)) }
58
+ private def parse_priority(name, config)
59
+ value = config["priority"]
60
+ return value if value.nil? || value.is_a?(Integer)
61
+
62
+ raise_invalid(name, :priority, value, "priority must be an integer")
63
+ end
64
+
65
+ # Que stores args as a json array. A single value (including a hash or nil) becomes an
66
+ # array of that one value.
67
+ sig { params(config: CONFIG).returns(T::Array[T.anything]) }
68
+ private def parse_args(config)
69
+ return [] unless config.key?("args")
70
+
71
+ args = config["args"]
72
+ args.is_a?(Array) ? args : [args]
73
+ end
74
+
75
+ sig { params(name: String, config: CONFIG).returns(ScheduleType) }
76
+ private def parse_schedule_type(name, config)
77
+ value = config["schedule_type"]
78
+ return ScheduleType::DEFAULT if value.nil?
79
+
80
+ reason = "Not in #{ScheduleType.values.map(&:serialize)}"
81
+ ScheduleType.try_deserialize(String(value)) ||
82
+ raise_invalid(name, :schedule_type, value, reason)
83
+ end
84
+
85
+ sig do
86
+ params(name: String, field: Symbol, value: T.anything, reason: String).returns(T.noreturn)
87
+ end
88
+ private def raise_invalid(name, field, value, reason)
89
+ location = Que::Scheduler.configuration.schedule_location
90
+ raise "Invalid #{field} '#{value}' for '#{name}' " \
91
+ "in que-scheduler schedule #{location}.\n#{reason}"
92
+ end
93
+ end
94
+ end
95
+ end
96
+ end
@@ -2,13 +2,16 @@
2
2
 
3
3
  require "fugit"
4
4
  require "sorbet-runtime"
5
+ require_relative "defined_job"
6
+ require_relative "scheduler_job_args"
7
+ require_relative "to_enqueue"
5
8
 
6
9
  module Que
7
10
  module Scheduler
8
11
  module EnqueueingCalculator
9
- class Result < Hashie::Dash
10
- property :missed_jobs, required: true
11
- property :job_dictionary, required: true
12
+ class Result < T::Struct
13
+ const :missed_jobs, T::Array[ToEnqueue]
14
+ const :job_dictionary, T::Array[String]
12
15
  end
13
16
 
14
17
  class << self
@@ -21,22 +24,22 @@ module Que
21
24
  ).returns(Result)
22
25
  end
23
26
  def parse(scheduler_config, scheduler_job_args)
24
- job_dictionary = []
27
+ job_dictionary = T.let([], T::Array[String])
25
28
 
26
29
  # For each scheduled item:
27
30
  # 1) If never seen before, schedule nothing, but add to known list
28
31
  # 2) If seen before, calculate what (if anything) needs to be enqueued.
29
- missed_jobs = scheduler_config.map do |desc|
32
+ missed_jobs = scheduler_config.flat_map do |desc|
30
33
  job_name = desc.name
31
34
  job_dictionary << job_name
32
35
 
33
36
  # If we have never seen this job before, we don't want to scheduled any jobs for it.
34
37
  # But we have added it to the dictionary, so it will be used to enqueue jobs next time.
35
- next unless scheduler_job_args.job_dictionary.include?(job_name)
38
+ next [] unless scheduler_job_args.job_dictionary.include?(job_name)
36
39
 
37
40
  # This has been seen before. We should check if we have missed any executions.
38
41
  desc.calculate_missed_runs(scheduler_job_args.last_run_time, scheduler_job_args.as_time)
39
- end.flatten.compact
42
+ end
40
43
 
41
44
  Result.new(missed_jobs: missed_jobs, job_dictionary: job_dictionary)
42
45
  end
@@ -0,0 +1,122 @@
1
+ # typed: true
2
+
3
+ require "json"
4
+ require "que"
5
+ require "sorbet-runtime"
6
+ require_relative "db_support"
7
+ require_relative "time_zone"
8
+
9
+ # Centralises the differences between enqueueing a Que::Job and an ActiveJob.
10
+ module Que
11
+ module Scheduler
12
+ module JobAdapter
13
+ extend T::Sig
14
+ extend T::Helpers
15
+
16
+ interface!
17
+
18
+ sig { abstract.params(to_enqueue: ToEnqueue).returns(T.nilable(EnqueuedJob)) }
19
+ def enqueue(to_enqueue); end
20
+ end
21
+
22
+ class QueAdapter
23
+ extend T::Sig
24
+ include JobAdapter
25
+
26
+ sig { override.params(to_enqueue: ToEnqueue).returns(T.nilable(EnqueuedJob)) }
27
+ def enqueue(to_enqueue)
28
+ job_options = {
29
+ queue: to_enqueue.queue,
30
+ priority: to_enqueue.priority,
31
+ run_at: to_enqueue.run_at,
32
+ }.compact
33
+ job = DbSupport.enqueue_a_job(
34
+ T.cast(to_enqueue.job_class, T.class_of(Que::Job)), job_options, to_enqueue.args
35
+ )
36
+ read_back(job) if job
37
+ end
38
+
39
+ # Read the job back to get the values that will be used when it is worked
40
+ sig { params(job: Que::Job).returns(EnqueuedJob) }
41
+ private def read_back(job)
42
+ attrs = DbSupport.job_attributes(job)
43
+ EnqueuedJob.new(
44
+ job_class: T.cast(attrs.fetch(:job_class), String),
45
+ args: T.cast(attrs.fetch(:args), T::Array[T.anything]),
46
+ queue: T.cast(attrs.fetch(:queue), String),
47
+ priority: T.cast(attrs.fetch(:priority), Integer),
48
+ run_at: T.cast(attrs.fetch(:run_at), Time),
49
+ job_id: DbSupport.job_id(job)
50
+ )
51
+ end
52
+ end
53
+
54
+ class ActiveJobAdapter
55
+ extend T::Sig
56
+ include JobAdapter
57
+
58
+ # Que's column default, which ActiveJob does not let us read back. t.ly/1jRK5
59
+ QUE_DEFAULT_PRIORITY = 100
60
+
61
+ sig { override.params(to_enqueue: ToEnqueue).returns(T.nilable(EnqueuedJob)) }
62
+ def enqueue(to_enqueue)
63
+ job_class = T.cast(to_enqueue.job_class, T.class_of(::ActiveJob::Base))
64
+ job_settings = {
65
+ priority: to_enqueue.priority,
66
+ wait_until: to_enqueue.run_at,
67
+ queue: to_enqueue.queue || Que::DEFAULT_QUEUE,
68
+ }.compact
69
+ # Sorbet cannot type a splat of an array of unknown size
70
+ job = T.unsafe(job_class.set(**job_settings)).perform_later(*to_enqueue.args)
71
+ read_back(job, to_enqueue) if job
72
+ end
73
+
74
+ sig { params(job: ::ActiveJob::Base, to_enqueue: ToEnqueue).returns(EnqueuedJob) }
75
+ private def read_back(job, to_enqueue)
76
+ data = JSON.parse(job.to_json, symbolize_names: true)
77
+ EnqueuedJob.new(
78
+ job_class: to_enqueue.job_class.to_s,
79
+ args: data.fetch(:arguments),
80
+ queue: data.fetch(:queue_name),
81
+ # The enqueued job does not expose its priority, so assume it is what we asked for
82
+ priority: to_enqueue.priority || QUE_DEFAULT_PRIORITY,
83
+ run_at: self.class.extract_scheduled_at(data.fetch(:scheduled_at)),
84
+ job_id: data.fetch(:provider_job_id)
85
+ )
86
+ end
87
+
88
+ # ActiveJob serialises scheduled_at as an ISO 8601 string; we want a Time
89
+ sig { params(scheduled_at: String).returns(ActiveSupport::TimeWithZone) }
90
+ def self.extract_scheduled_at(scheduled_at)
91
+ T.must(Que::Scheduler::TimeZone.time_zone.parse(scheduled_at))
92
+ end
93
+ end
94
+
95
+ module JobAdapters
96
+ QUE_ADAPTER = QueAdapter.new
97
+ ACTIVE_JOB_ADAPTER = ActiveJobAdapter.new
98
+
99
+ class << self
100
+ extend T::Sig
101
+
102
+ sig { returns(T::Boolean) }
103
+ def active_job_defined? = Object.const_defined?(:ActiveJob)
104
+
105
+ sig { params(job_class: Class).returns(T::Boolean) }
106
+ def supports?(job_class) = !!find(job_class)
107
+
108
+ sig { params(job_class: Class).returns(JobAdapter) }
109
+ def adapter_for(job_class) = T.must(find(job_class))
110
+
111
+ sig { params(job_class: Class).returns(T.nilable(JobAdapter)) }
112
+ private def find(job_class)
113
+ if job_class < ::Que::Job
114
+ QUE_ADAPTER
115
+ elsif active_job_defined? && job_class < ::ActiveJob::Base
116
+ ACTIVE_JOB_ADAPTER
117
+ end
118
+ end
119
+ end
120
+ end
121
+ end
122
+ end
@@ -3,6 +3,7 @@
3
3
  require "sorbet-runtime"
4
4
  require_relative "configuration"
5
5
  require_relative "defined_job"
6
+ require_relative "defined_job_parser"
6
7
  require_relative "types"
7
8
 
8
9
  module Que
@@ -55,40 +56,11 @@ module Que
55
56
  sig do
56
57
  params(
57
58
  name: String,
58
- defined_job_hash_in: T::Hash[T.any(String, Symbol), T.anything]
59
+ defined_job_hash_in: T::Hash[T.any(String, Symbol), Object]
59
60
  ).returns(DefinedJob)
60
61
  end
61
62
  def hash_item_to_defined_job(name, defined_job_hash_in)
62
- defined_job_hash = defined_job_hash_in.stringify_keys
63
- # Que stores arguments as a json array. If the args we have to provide are already an
64
- # array we can can simply pass them through. If it is a single non-nil value, then we make
65
- # an array with one item which is that value (this includes if it is a hash). It could
66
- # also be a single nil value.
67
- args_array =
68
- if defined_job_hash.key?("args")
69
- args = defined_job_hash["args"]
70
- if args.is_a?(Array)
71
- # An array of args was requested
72
- args
73
- else
74
- # A single value, a nil, or a hash was requested. que expects this to
75
- # be enqueued as an array of 1 item
76
- [args]
77
- end
78
- else
79
- # No args were requested
80
- []
81
- end
82
-
83
- Que::Scheduler::DefinedJob.create(
84
- name: name,
85
- job_class: defined_job_hash["class"]&.to_s || name,
86
- queue: defined_job_hash["queue"],
87
- args_array: args_array,
88
- priority: defined_job_hash["priority"],
89
- cron: defined_job_hash["cron"],
90
- schedule_type: defined_job_hash["schedule_type"]&.to_sym
91
- )
63
+ DefinedJobParser.parse(name, defined_job_hash_in.stringify_keys)
92
64
  end
93
65
  end
94
66
  end
@@ -0,0 +1,17 @@
1
+ # typed: true
2
+
3
+ require "sorbet-runtime"
4
+
5
+ module Que
6
+ module Scheduler
7
+ # How a job catches up on runs missed while the scheduler was not running.
8
+ class ScheduleType < T::Enum
9
+ enums do
10
+ # Enqueue only the most recent missed run
11
+ DEFAULT = new("default")
12
+ # Enqueue every missed run, passing each its scheduled time as the first arg
13
+ EVERY_EVENT = new("every_event")
14
+ end
15
+ end
16
+ end
17
+ end
@@ -30,7 +30,7 @@ module Que
30
30
  self.maximum_retry_count = 1 << 128 # Heat death of universe
31
31
  self.retry_interval = RETRY_PROC
32
32
 
33
- sig { params(options: T.nilable(T::Hash[T.any(String, Symbol), T.anything])).void }
33
+ sig { params(options: T.nilable(T::Hash[T.any(String, Symbol), Object])).void }
34
34
  def run(options = nil)
35
35
  Que::Scheduler::Db.transaction do
36
36
  Que::Scheduler::StateChecks.check
@@ -53,7 +53,7 @@ module Que
53
53
  params(
54
54
  calculator_result: EnqueueingCalculator::Result,
55
55
  logs: T::Array[String]
56
- ).returns(T::Array[EnqueuedJobType])
56
+ ).returns(T::Array[EnqueuedJob])
57
57
  end
58
58
  def enqueue_required_jobs(calculator_result, logs)
59
59
  calculator_result.missed_jobs.filter_map do |to_enqueue|
@@ -66,7 +66,7 @@ module Que
66
66
  sig do
67
67
  params(
68
68
  to_enqueue: ToEnqueue,
69
- enqueued_job: T.nilable(EnqueuedJobType),
69
+ enqueued_job: T.nilable(EnqueuedJob),
70
70
  logs: T::Array[String]
71
71
  ).void
72
72
  end
@@ -86,7 +86,7 @@ module Que
86
86
  scheduler_job_args: SchedulerJobArgs,
87
87
  last_full_execution: Types::TIME,
88
88
  job_dictionary: T::Array[String],
89
- enqueued_jobs: T::Array[EnqueuedJobType]
89
+ enqueued_jobs: T::Array[EnqueuedJob]
90
90
  ).void
91
91
  end
92
92
  private def enqueue_self_again(scheduler_job_args, last_full_execution, job_dictionary,
@@ -103,17 +103,12 @@ module Que
103
103
  queue: Que::Scheduler.configuration.que_scheduler_queue,
104
104
  run_at: next_run_at,
105
105
  },
106
- {
107
- last_run_time: last_full_execution.iso8601,
108
- job_dictionary: job_dictionary,
109
- }
106
+ [{ last_run_time: last_full_execution.iso8601, job_dictionary: job_dictionary }]
110
107
  )
111
108
 
112
- # rubocop:disable Style/GuardClause -- This reads better as a conditional
113
- unless enqueued_job && DbSupport.job_attributes(enqueued_job).fetch(:job_id)
114
- raise "SchedulerJob could not self-schedule. Has `.enqueue` been monkey patched?"
115
- end
116
- # rubocop:enable Style/GuardClause
109
+ return if enqueued_job
110
+
111
+ raise "SchedulerJob could not self-schedule. Has `.enqueue` been monkey patched?"
117
112
  end
118
113
  end
119
114
  end
@@ -1,39 +1,33 @@
1
1
  # typed: true
2
2
 
3
- require "hashie"
4
3
  require "sorbet-runtime"
5
4
  require "active_support"
6
5
  require "active_support/time_with_zone"
6
+ require_relative "types"
7
7
 
8
8
  # These are the args that are used for this particular run of the scheduler.
9
9
  module Que
10
10
  module Scheduler
11
- class SchedulerJobArgs < Hashie::Dash
12
- property :last_run_time, required: true
13
- property :job_dictionary, required: true
14
- property :as_time, required: true
15
-
11
+ class SchedulerJobArgs < T::Struct
16
12
  extend T::Sig
17
13
 
18
- sig { params(options: T.nilable(T::Hash[T.any(String, Symbol), T.anything])).returns(SchedulerJobArgs) }
14
+ const :last_run_time, Types::TIME
15
+ const :job_dictionary, T::Array[String]
16
+ const :as_time, Types::TIME
17
+
18
+ sig { params(options: T.nilable(T::Hash[T.any(String, Symbol), Object])).returns(SchedulerJobArgs) }
19
19
  def self.build(options)
20
20
  now = Que::Scheduler::Db.now
21
- parsed =
22
- if options.nil?
23
- # First ever run, there is nothing to do but reschedule self to run on the next minute.
24
- {
25
- last_run_time: now,
26
- job_dictionary: [],
27
- }
28
- else
29
- options = options.symbolize_keys
30
- {
31
- last_run_time:
32
- Que::Scheduler::TimeZone.time_zone.parse(options.fetch(:last_run_time)),
33
- job_dictionary: options.fetch(:job_dictionary),
34
- }
35
- end
36
- SchedulerJobArgs.new(parsed.merge(as_time: now))
21
+ # First ever run, there is nothing to do but reschedule self to run on the next minute.
22
+ return new(last_run_time: now, job_dictionary: [], as_time: now) if options.nil?
23
+
24
+ options = options.symbolize_keys
25
+ last_run_time = String(options.fetch(:last_run_time))
26
+ new(
27
+ last_run_time: T.must(Que::Scheduler::TimeZone.time_zone.parse(last_run_time)),
28
+ job_dictionary: T.cast(options.fetch(:job_dictionary), T::Array[String]),
29
+ as_time: now
30
+ )
37
31
  end
38
32
  end
39
33
  end
@@ -1,194 +1,34 @@
1
1
  # typed: true
2
2
 
3
- require "que"
4
3
  require "sorbet-runtime"
4
+ require_relative "job_adapters"
5
+ require_relative "types"
5
6
 
6
- # This module uses polymorphic dispatch to centralise the differences between supporting Que::Job
7
- # and other job systems.
8
7
  module Que
9
8
  module Scheduler
10
- class ToEnqueue < Hashie::Dash
9
+ # A job the scheduler has decided to enqueue.
10
+ class ToEnqueue < T::Struct
11
11
  extend T::Sig
12
- extend T::Helpers
13
12
 
14
- abstract!
13
+ const :job_class, Class
14
+ const :args, T::Array[T.anything], default: []
15
+ # nil means use the job class's own queue and priority
16
+ const :queue, T.nilable(String)
17
+ const :priority, T.nilable(Integer)
18
+ const :run_at, Types::TIME
15
19
 
16
- property :args, required: true, default: []
17
- property :queue
18
- property :priority
19
- property :run_at, required: true
20
- property :job_class, required: true
21
-
22
- sig { abstract.returns(T.nilable(EnqueuedJobType)) }
23
- def enqueue; end
24
-
25
- class << self
26
- extend T::Sig
27
-
28
- sig { params(options: T::Hash[Symbol, T.anything]).returns(ToEnqueue) }
29
- def create(options)
30
- T.must(type_from_job_class(T.cast(options.fetch(:job_class), Class))).new(
31
- options.merge(run_at: options.fetch(:run_at) { Que::Scheduler::Db.now })
32
- )
33
- end
34
-
35
- sig { params(job_class: Class).returns(T::Boolean) }
36
- def valid_job_class?(job_class)
37
- type_from_job_class(job_class).present?
38
- end
39
-
40
- sig { returns(T::Boolean) }
41
- def active_job_defined?
42
- Object.const_defined?(:ActiveJob)
43
- end
44
-
45
- sig { returns(T.nilable(Gem::Version)) }
46
- def active_job_version
47
- Gem.loaded_specs["activejob"]&.version
48
- end
49
-
50
- sig { returns(T::Boolean) }
51
- def active_job_version_supports_queues?
52
- # Supporting queue name in ActiveJob was removed in Rails 4.2.3
53
- # https://github.com/rails/rails/pull/19498
54
- # and readded in Rails 6.0.3
55
- # https://github.com/rails/rails/pull/38635
56
- version = ToEnqueue.active_job_version
57
- !version.nil? && version >= Gem::Version.create("6.0.3")
58
- end
59
-
60
- sig { params(job_class: Class).returns(T.nilable(T.class_of(ToEnqueue))) }
61
- private def type_from_job_class(job_class)
62
- types.each do |type, implementation|
63
- return implementation if job_class < type
64
- end
65
- nil
66
- end
67
-
68
- sig { returns(T::Hash[Class, T.class_of(ToEnqueue)]) }
69
- private def types
70
- @types ||=
71
- begin
72
- hash = {
73
- ::Que::Job => QueJobType,
74
- }
75
- hash[::ActiveJob::Base] = ActiveJobType if ToEnqueue.active_job_defined?
76
- hash
77
- end
78
- end
79
- end
80
- end
81
-
82
- # For jobs of type Que::Job
83
- class QueJobType < ToEnqueue
84
- sig { override.returns(T.nilable(EnqueuedJobType)) }
85
- def enqueue
86
- job_settings = to_h.slice(:queue, :priority, :run_at).compact
87
- job = Que::Scheduler::DbSupport.enqueue_a_job(
88
- T.cast(job_class, T.class_of(Que::Job)),
89
- job_settings,
90
- args
91
- )
92
-
93
- return nil if job.nil? || !job # nil in Rails < 6.1, false after.
94
-
95
- # Now read the just inserted job back out of the DB to get the actual values that will
96
- # be used when the job is worked.
97
- values = Que::Scheduler::DbSupport.job_attributes(job).slice(
98
- :args, :queue, :priority, :run_at, :job_class, :job_id
99
- )
100
- EnqueuedJobType.new(values)
101
- end
102
- end
103
-
104
- # For jobs of type ActiveJob
105
- class ActiveJobType < ToEnqueue
106
- sig { override.returns(T.nilable(EnqueuedJobType)) }
107
- def enqueue
108
- job = enqueue_active_job
109
-
110
- return nil if job.nil? || !job # nil in Rails < 6.1, false after.
111
-
112
- enqueued_values = calculate_enqueued_values(job)
113
- EnqueuedJobType.new(enqueued_values)
114
- end
115
-
116
- sig { params(job: ::ActiveJob::Base).returns(T::Hash[Symbol, T.anything]) }
117
- def calculate_enqueued_values(job)
118
- # Now read the just inserted job back out of the DB to get the actual values that will
119
- # be used when the job is worked.
120
- data = JSON.parse(job.to_json, symbolize_names: true)
121
-
122
- scheduled_at = self.class.extract_scheduled_at(data[:scheduled_at])
123
-
124
- # Rails didn't support queues for ActiveJob for a while
125
- used_queue = data[:queue_name] if ToEnqueue.active_job_version_supports_queues?
126
-
127
- # We can't get the priority out of the DB, as the returned `job` doesn't give us access
128
- # to the underlying ActiveJob that was scheduled. We have no option but to assume
129
- # it was what we told it to use. If no priority was specified, we must assume it was
130
- # the Que default, which is 100 t.ly/1jRK5
131
- assume_used_priority = priority.nil? ? 100 : priority
132
-
133
- {
134
- args: data.fetch(:arguments),
135
- queue: used_queue,
136
- priority: assume_used_priority,
137
- run_at: scheduled_at,
138
- job_class: job_class.to_s,
139
- job_id: data.fetch(:provider_job_id),
140
- }
141
- end
142
-
143
- sig { returns(T.nilable(T.any(::ActiveJob::Base, FalseClass))) }
144
- def enqueue_active_job
145
- job_settings = {
146
- priority: priority,
147
- wait_until: run_at,
148
- queue: queue || Que::DEFAULT_QUEUE,
149
- }.compact
150
-
151
- job_class_set = T.cast(job_class, T.class_of(::ActiveJob::Base)).set(**job_settings)
152
- job_args = args
153
- case job_args
154
- when Hash
155
- job_class_set.perform_later(**job_args)
156
- else
157
- # Sorbet cannot type a splat of a value of unknown size
158
- T.unsafe(job_class_set).perform_later(*job_args)
159
- end
160
- end
161
-
162
- class << self
163
- extend T::Sig
164
-
165
- # ActiveJob scheduled_at is returned as a float, or a string post Rails 7.1,
166
- # and we want a Time for consistency
167
- sig { params(scheduled_at: T.anything).returns(T.nilable(ActiveSupport::TimeWithZone)) }
168
- def extract_scheduled_at(scheduled_at)
169
- # rubocop:disable Style/EmptyElse
170
- case scheduled_at
171
- when Float
172
- Que::Scheduler::TimeZone.time_zone.at(scheduled_at)
173
- when String
174
- Que::Scheduler::TimeZone.time_zone.parse(scheduled_at)
175
- else
176
- nil
177
- end
178
- # rubocop:enable Style/EmptyElse
179
- end
180
- end
20
+ sig { returns(T.nilable(EnqueuedJob)) }
21
+ def enqueue = JobAdapters.adapter_for(job_class).enqueue(self)
181
22
  end
182
23
 
183
- # A value object returned after a job has been enqueued. This is necessary as Que (normal) and
184
- # ActiveJob return very different objects from the `enqueue` call.
185
- class EnqueuedJobType < Hashie::Dash
186
- property :args
187
- property :queue
188
- property :priority
189
- property :run_at, required: true
190
- property :job_class, required: true
191
- property :job_id, required: true
24
+ # A job as it was actually enqueued, read back after the enqueue.
25
+ class EnqueuedJob < T::Struct
26
+ const :job_class, String
27
+ const :args, T::Array[T.anything]
28
+ const :queue, String
29
+ const :priority, Integer
30
+ const :run_at, Types::TIME
31
+ const :job_id, Integer
192
32
  end
193
33
  end
194
34
  end
@@ -11,7 +11,7 @@ module Que
11
11
  TIME = T.type_alias { T.any(Time, ActiveSupport::TimeWithZone) }
12
12
  # A schedule as given in config: job name to its options
13
13
  SCHEDULE_CONFIG = T.type_alias do
14
- T::Hash[T.any(String, Symbol), T::Hash[T.any(String, Symbol), T.anything]]
14
+ T::Hash[T.any(String, Symbol), T::Hash[T.any(String, Symbol), Object]]
15
15
  end
16
16
  end
17
17
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Que
4
4
  module Scheduler
5
- VERSION = "7.2.0".freeze
5
+ VERSION = "9.0.0".freeze
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: que-scheduler
3
3
  version: !ruby/object:Gem::Version
4
- version: 7.2.0
4
+ version: 9.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Harry Lascelles
8
- autorequire:
9
8
  bindir: bin
10
9
  cert_chain: []
11
- date: 2026-10-11 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: activesupport
@@ -16,14 +15,14 @@ dependencies:
16
15
  requirements:
17
16
  - - ">="
18
17
  - !ruby/object:Gem::Version
19
- version: '6.0'
18
+ version: '8.0'
20
19
  type: :runtime
21
20
  prerelease: false
22
21
  version_requirements: !ruby/object:Gem::Requirement
23
22
  requirements:
24
23
  - - ">="
25
24
  - !ruby/object:Gem::Version
26
- version: '6.0'
25
+ version: '8.0'
27
26
  - !ruby/object:Gem::Dependency
28
27
  name: fugit
29
28
  requirement: !ruby/object:Gem::Requirement
@@ -44,26 +43,6 @@ dependencies:
44
43
  - - ">="
45
44
  - !ruby/object:Gem::Version
46
45
  version: 1.11.1
47
- - !ruby/object:Gem::Dependency
48
- name: hashie
49
- requirement: !ruby/object:Gem::Requirement
50
- requirements:
51
- - - ">="
52
- - !ruby/object:Gem::Version
53
- version: '3'
54
- - - "<"
55
- - !ruby/object:Gem::Version
56
- version: '6'
57
- type: :runtime
58
- prerelease: false
59
- version_requirements: !ruby/object:Gem::Requirement
60
- requirements:
61
- - - ">="
62
- - !ruby/object:Gem::Version
63
- version: '3'
64
- - - "<"
65
- - !ruby/object:Gem::Version
66
- version: '6'
67
46
  - !ruby/object:Gem::Dependency
68
47
  name: que
69
48
  requirement: !ruby/object:Gem::Requirement
@@ -104,7 +83,7 @@ dependencies:
104
83
  requirements:
105
84
  - - ">="
106
85
  - !ruby/object:Gem::Version
107
- version: '7.0'
86
+ version: '8.0'
108
87
  - - "<"
109
88
  - !ruby/object:Gem::Version
110
89
  version: '9.0'
@@ -114,7 +93,7 @@ dependencies:
114
93
  requirements:
115
94
  - - ">="
116
95
  - !ruby/object:Gem::Version
117
- version: '7.0'
96
+ version: '8.0'
118
97
  - - "<"
119
98
  - !ruby/object:Gem::Version
120
99
  version: '9.0'
@@ -132,20 +111,6 @@ dependencies:
132
111
  - - ">="
133
112
  - !ruby/object:Gem::Version
134
113
  version: '0'
135
- - !ruby/object:Gem::Dependency
136
- name: base64
137
- requirement: !ruby/object:Gem::Requirement
138
- requirements:
139
- - - ">="
140
- - !ruby/object:Gem::Version
141
- version: '0'
142
- type: :development
143
- prerelease: false
144
- version_requirements: !ruby/object:Gem::Requirement
145
- requirements:
146
- - - ">="
147
- - !ruby/object:Gem::Version
148
- version: '0'
149
114
  - !ruby/object:Gem::Dependency
150
115
  name: climate_control
151
116
  requirement: !ruby/object:Gem::Requirement
@@ -188,20 +153,6 @@ dependencies:
188
153
  - - ">="
189
154
  - !ruby/object:Gem::Version
190
155
  version: '0'
191
- - !ruby/object:Gem::Dependency
192
- name: database_cleaner
193
- requirement: !ruby/object:Gem::Requirement
194
- requirements:
195
- - - ">="
196
- - !ruby/object:Gem::Version
197
- version: '0'
198
- type: :development
199
- prerelease: false
200
- version_requirements: !ruby/object:Gem::Requirement
201
- requirements:
202
- - - ">="
203
- - !ruby/object:Gem::Version
204
- version: '0'
205
156
  - !ruby/object:Gem::Dependency
206
157
  name: dememoize
207
158
  requirement: !ruby/object:Gem::Requirement
@@ -230,20 +181,6 @@ dependencies:
230
181
  - - ">="
231
182
  - !ruby/object:Gem::Version
232
183
  version: '0'
233
- - !ruby/object:Gem::Dependency
234
- name: mutex_m
235
- requirement: !ruby/object:Gem::Requirement
236
- requirements:
237
- - - ">="
238
- - !ruby/object:Gem::Version
239
- version: '0'
240
- type: :development
241
- prerelease: false
242
- version_requirements: !ruby/object:Gem::Requirement
243
- requirements:
244
- - - ">="
245
- - !ruby/object:Gem::Version
246
- version: '0'
247
184
  - !ruby/object:Gem::Dependency
248
185
  name: pg
249
186
  requirement: !ruby/object:Gem::Requirement
@@ -441,7 +378,9 @@ files:
441
378
  - lib/que/scheduler/db.rb
442
379
  - lib/que/scheduler/db_support.rb
443
380
  - lib/que/scheduler/defined_job.rb
381
+ - lib/que/scheduler/defined_job_parser.rb
444
382
  - lib/que/scheduler/enqueueing_calculator.rb
383
+ - lib/que/scheduler/job_adapters.rb
445
384
  - lib/que/scheduler/jobs/que_scheduler_audit_clear_down_job.rb
446
385
  - lib/que/scheduler/migrations.rb
447
386
  - lib/que/scheduler/migrations/1/down.sql
@@ -461,6 +400,7 @@ files:
461
400
  - lib/que/scheduler/migrations/8/down.sql
462
401
  - lib/que/scheduler/migrations/8/up.sql
463
402
  - lib/que/scheduler/schedule.rb
403
+ - lib/que/scheduler/schedule_type.rb
464
404
  - lib/que/scheduler/scheduler_job.rb
465
405
  - lib/que/scheduler/scheduler_job_args.rb
466
406
  - lib/que/scheduler/state_checks.rb
@@ -478,7 +418,6 @@ metadata:
478
418
  source_code_uri: https://github.com/hlascelles/que-scheduler/
479
419
  bug_tracker_uri: https://github.com/hlascelles/que-scheduler/issues
480
420
  rubygems_mfa_required: 'true'
481
- post_install_message:
482
421
  rdoc_options: []
483
422
  require_paths:
484
423
  - lib
@@ -486,15 +425,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
486
425
  requirements:
487
426
  - - ">="
488
427
  - !ruby/object:Gem::Version
489
- version: '3.1'
428
+ version: '4.0'
490
429
  required_rubygems_version: !ruby/object:Gem::Requirement
491
430
  requirements:
492
431
  - - ">="
493
432
  - !ruby/object:Gem::Version
494
433
  version: '0'
495
434
  requirements: []
496
- rubygems_version: 3.5.22
497
- signing_key:
435
+ rubygems_version: 4.0.20
498
436
  specification_version: 4
499
437
  summary: A cron scheduler for Que
500
438
  test_files: []