exekutor 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (49) hide show
  1. checksums.yaml +7 -0
  2. checksums.yaml.gz.sig +3 -0
  3. data/LICENSE.txt +21 -0
  4. data/exe/exekutor +7 -0
  5. data/lib/active_job/queue_adapters/exekutor_adapter.rb +14 -0
  6. data/lib/exekutor/asynchronous.rb +188 -0
  7. data/lib/exekutor/cleanup.rb +56 -0
  8. data/lib/exekutor/configuration.rb +373 -0
  9. data/lib/exekutor/hook.rb +172 -0
  10. data/lib/exekutor/info/worker.rb +20 -0
  11. data/lib/exekutor/internal/base_record.rb +11 -0
  12. data/lib/exekutor/internal/callbacks.rb +138 -0
  13. data/lib/exekutor/internal/cli/app.rb +173 -0
  14. data/lib/exekutor/internal/cli/application_loader.rb +36 -0
  15. data/lib/exekutor/internal/cli/cleanup.rb +96 -0
  16. data/lib/exekutor/internal/cli/daemon.rb +108 -0
  17. data/lib/exekutor/internal/cli/default_option_value.rb +29 -0
  18. data/lib/exekutor/internal/cli/info.rb +126 -0
  19. data/lib/exekutor/internal/cli/manager.rb +260 -0
  20. data/lib/exekutor/internal/configuration_builder.rb +113 -0
  21. data/lib/exekutor/internal/database_connection.rb +21 -0
  22. data/lib/exekutor/internal/executable.rb +75 -0
  23. data/lib/exekutor/internal/executor.rb +242 -0
  24. data/lib/exekutor/internal/hooks.rb +87 -0
  25. data/lib/exekutor/internal/listener.rb +176 -0
  26. data/lib/exekutor/internal/logger.rb +74 -0
  27. data/lib/exekutor/internal/provider.rb +308 -0
  28. data/lib/exekutor/internal/reserver.rb +95 -0
  29. data/lib/exekutor/internal/status_server.rb +132 -0
  30. data/lib/exekutor/job.rb +31 -0
  31. data/lib/exekutor/job_error.rb +11 -0
  32. data/lib/exekutor/job_options.rb +95 -0
  33. data/lib/exekutor/plugins/appsignal.rb +46 -0
  34. data/lib/exekutor/plugins.rb +13 -0
  35. data/lib/exekutor/queue.rb +141 -0
  36. data/lib/exekutor/version.rb +6 -0
  37. data/lib/exekutor/worker.rb +219 -0
  38. data/lib/exekutor.rb +49 -0
  39. data/lib/generators/exekutor/configuration_generator.rb +18 -0
  40. data/lib/generators/exekutor/install_generator.rb +43 -0
  41. data/lib/generators/exekutor/templates/install/functions/job_notifier.sql +7 -0
  42. data/lib/generators/exekutor/templates/install/functions/requeue_orphaned_jobs.sql +7 -0
  43. data/lib/generators/exekutor/templates/install/initializers/exekutor.rb.erb +14 -0
  44. data/lib/generators/exekutor/templates/install/migrations/create_exekutor_schema.rb.erb +83 -0
  45. data/lib/generators/exekutor/templates/install/triggers/notify_workers.sql +6 -0
  46. data/lib/generators/exekutor/templates/install/triggers/requeue_orphaned_jobs.sql +5 -0
  47. data.tar.gz.sig +0 -0
  48. metadata +403 -0
  49. metadata.gz.sig +0 -0
@@ -0,0 +1,308 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "executable"
4
+ require_relative "callbacks"
5
+
6
+ module Exekutor
7
+ # @private
8
+ module Internal
9
+ # Reserves jobs and provides them to an executor
10
+ class Provider
11
+ include Executable, Callbacks, Logger
12
+
13
+ define_callbacks :on_queue_empty, freeze: true
14
+
15
+ # Represents an unknown value
16
+ UNKNOWN = Object.new.freeze
17
+ private_constant "UNKNOWN"
18
+
19
+ MAX_WAIT_TIMEOUT = 300
20
+ private_constant "MAX_WAIT_TIMEOUT"
21
+
22
+ # Creates a new provider
23
+ # @param reserver [Reserver] the job reserver
24
+ # @param executor [Executor] the job executor
25
+ # @param pool [ThreadPoolExecutor] the thread pool to use
26
+ # @param polling_interval [Integer] the polling interval
27
+ # @param interval_jitter [Float] the polling interval jitter
28
+ def initialize(reserver:, executor:, pool:, polling_interval: 60,
29
+ interval_jitter: polling_interval.to_i > 1 ? polling_interval * 0.1 : 0)
30
+ super()
31
+ @reserver = reserver
32
+ @executor = executor
33
+ @pool = pool
34
+
35
+ @polling_interval = polling_interval.freeze
36
+ @interval_jitter = interval_jitter.to_f.freeze
37
+
38
+ @event = Concurrent::Event.new
39
+ @thread_running = Concurrent::AtomicBoolean.new false
40
+
41
+ @next_job_scheduled_at = Concurrent::AtomicReference.new UNKNOWN
42
+ @next_poll_at = Concurrent::AtomicReference.new nil
43
+ end
44
+
45
+ # Starts the provider.
46
+ def start
47
+ return false unless compare_and_set_state :pending, :started
48
+
49
+ # Always poll at startup to fill up threads, use small jitter so workers started at the same time dont hit
50
+ # the db at the same time
51
+ @next_poll_at.set (1 + 2 * Kernel.rand).second.from_now
52
+ start_thread
53
+ true
54
+ end
55
+
56
+ # Stops the provider
57
+ def stop
58
+ set_state :stopped
59
+ @event.set
60
+ end
61
+
62
+ # Makes the provider poll for jobs
63
+ def poll
64
+ raise Exekutor::Error, "Provider is not running" unless running?
65
+
66
+ @next_poll_at.set Time.now
67
+ @event.set
68
+ end
69
+
70
+ # Updates the timestamp for when the next job is scheduled. Gets the earliest scheduled_at from the DB if no
71
+ # argument is given. Updates the timestamp for the earliest job is a timestamp is given and that timestamp is
72
+ # before the known timestamp. Does nothing if a timestamp is given and the earliest job timestamp is not known.
73
+ # @param scheduled_at [Time,Numeric] the time a job is scheduled at
74
+ # @return [Time] the timestamp for the next job, or +nil+ if the timestamp is unknown or no jobs are pending
75
+ def update_earliest_scheduled_at(scheduled_at = UNKNOWN)
76
+ overwrite_unknown = false
77
+ case scheduled_at
78
+ when UNKNOWN
79
+ # If we fetch the value from the DB, we can safely overwrite the UNKNOWN value
80
+ overwrite_unknown = true
81
+ scheduled_at = @reserver.earliest_scheduled_at
82
+ when Numeric
83
+ scheduled_at = Time.at(scheduled_at)
84
+ when Time
85
+ # All good
86
+ else
87
+ raise ArgumentError, "scheduled_at must be a Time or Numeric"
88
+ end
89
+
90
+ updated = false
91
+ scheduled_at = @next_job_scheduled_at.update do |current|
92
+ if current == UNKNOWN
93
+ if overwrite_unknown || scheduled_at <= Time.now
94
+ updated = true
95
+ scheduled_at
96
+ else
97
+ current
98
+ end
99
+ elsif current.nil? || scheduled_at.nil? || current > scheduled_at
100
+ updated = true
101
+ scheduled_at
102
+ else
103
+ current
104
+ end
105
+ end
106
+ if scheduled_at == UNKNOWN
107
+ nil
108
+ else
109
+ @event.set if updated && scheduled_at.present?
110
+ scheduled_at
111
+ end
112
+ end
113
+
114
+ private
115
+
116
+ # Starts the provision thread
117
+ def start_thread
118
+ @pool.post(&method(:run)) if running?
119
+ end
120
+
121
+ # Does the provisioning of jobs to the executor. Blocks until the provider is stopped.
122
+ def run
123
+ return unless running? && @thread_running.make_true
124
+
125
+ DatabaseConnection.ensure_active!
126
+ perform_pending_job_updates
127
+ restart_abandoned_jobs
128
+ catch(:shutdown) do
129
+ while running?
130
+ wait_for_event
131
+ next unless reserve_jobs_now?
132
+
133
+ reserve_and_execute_jobs
134
+ consecutive_errors.value = 0
135
+ end
136
+ end
137
+ rescue StandardError => err
138
+ Exekutor.on_fatal_error err, "[Provider] Runtime error!"
139
+ consecutive_errors.increment
140
+ if running?
141
+ delay = restart_delay
142
+ logger.info "Restarting in %0.1f seconds…" % [delay]
143
+ Concurrent::ScheduledTask.execute(delay, executor: @pool, &method(:run))
144
+ end
145
+ ensure
146
+ BaseRecord.connection_pool.release_connection
147
+ @thread_running.make_false
148
+ end
149
+
150
+ # Waits for any event to happen. An event could be:
151
+ # - The listener was notified of a new job;
152
+ # - The next job is scheduled for the current time;
153
+ # - The polling interval;
154
+ # - A call to {#poll}
155
+ def wait_for_event
156
+ timeout = wait_timeout
157
+ return unless timeout.positive?
158
+
159
+ @event.wait timeout
160
+ rescue StandardError => err
161
+ Exekutor.on_fatal_error err, "[Provider] An error occurred while waiting"
162
+ sleep 0.1 if running?
163
+ ensure
164
+ throw :shutdown unless running?
165
+ @event.reset
166
+ end
167
+
168
+ # Reserves jobs and posts them to the executor
169
+ def reserve_and_execute_jobs
170
+ available_workers = @executor.available_threads
171
+ return unless available_workers.positive?
172
+
173
+ jobs = @reserver.reserve available_workers
174
+ unless jobs.nil?
175
+ begin
176
+ logger.debug "Reserved #{jobs.size} job(s)"
177
+ jobs.each(&@executor.method(:post))
178
+ rescue Exception # rubocop:disable Lint/RescueException
179
+ # Try to release all jobs before re-raising
180
+ begin
181
+ Exekutor::Job.where(id: jobs.collect { |job| job[:id] }, status: "e")
182
+ .update_all(status: "p", worker_id: nil)
183
+ rescue # rubocop:disable Lint/RescueStandardError
184
+ # ignored
185
+ end
186
+ raise
187
+ end
188
+ end
189
+ if jobs.nil? || jobs.size.to_i < available_workers
190
+ # If we ran out of work, update the earliest scheduled at
191
+ update_earliest_scheduled_at
192
+
193
+ run_callbacks :on, :queue_empty if jobs.nil?
194
+
195
+ elsif @next_job_scheduled_at.get == UNKNOWN
196
+ # If the next job timestamp is still unknown, set it to now to indicate there's still work to do
197
+ @next_job_scheduled_at.set Time.now
198
+ end
199
+ end
200
+
201
+ def perform_pending_job_updates
202
+ updates = @executor.pending_job_updates
203
+ while (id, attrs = updates.shift).present?
204
+ begin
205
+ if attrs == :destroy
206
+ Exekutor::Job.destroy(id)
207
+ else
208
+ Exekutor::Job.where(id: id).update_all(attrs)
209
+ end
210
+ rescue ActiveRecord::StatementInvalid, ActiveRecord::ConnectionNotEstablished
211
+ unless Exekutor::Job.connection.active?
212
+ # Connection lost again, requeue update and avoid trying further updates
213
+ updates[id] ||= attrs
214
+ return
215
+ end
216
+ end
217
+ end
218
+ end
219
+
220
+ # Restarts all jobs that have the 'executing' status but are no longer running. Releases the jobs if the
221
+ # execution thread pool is full.
222
+ def restart_abandoned_jobs
223
+ jobs = @reserver.get_abandoned_jobs(@executor.active_job_ids)
224
+ return if jobs&.size.to_i.zero?
225
+
226
+ logger.info "Restarting #{jobs.size} abandoned job#{"s" if jobs.size > 1}"
227
+ jobs.each(&@executor.method(:post))
228
+ end
229
+
230
+ # @return [Boolean] Whether the polling is enabled. Ie. whether a polling interval is set.
231
+ def polling_enabled?
232
+ @polling_interval.present?
233
+ end
234
+
235
+ # @return [Time,nil] the 'scheduled at' value for the next job, or nil if unknown or if there is no pending job
236
+ def next_job_scheduled_at
237
+ at = @next_job_scheduled_at.get
238
+ if at == UNKNOWN
239
+ nil
240
+ else
241
+ # noinspection RubyMismatchedReturnType
242
+ at
243
+ end
244
+ end
245
+
246
+ # @return [Time,nil] When the next poll is scheduled, or nil if polling is disabled
247
+ def next_poll_scheduled_at
248
+ if polling_enabled?
249
+ @next_poll_at.update { |planned_at| planned_at || Time.now + polling_interval }
250
+ else
251
+ # noinspection RubyMismatchedReturnType
252
+ @next_poll_at.get
253
+ end
254
+ end
255
+
256
+ # @return [Numeric] The timeout to wait until the next event
257
+ def wait_timeout
258
+ return MAX_WAIT_TIMEOUT if @executor.available_threads.zero?
259
+
260
+ next_job_at = next_job_scheduled_at
261
+ next_poll_at = next_poll_scheduled_at
262
+
263
+ timeout = [MAX_WAIT_TIMEOUT].tap do |timeouts|
264
+ # noinspection RubyMismatchedArgumentType
265
+ timeouts.append next_job_at - Time.now if next_job_at
266
+ # noinspection RubyMismatchedArgumentType
267
+ timeouts.append next_poll_at - Time.now if next_poll_at
268
+ end.min
269
+
270
+ if timeout <= 0.001
271
+ 0
272
+ else
273
+ # noinspection RubyMismatchedReturnType
274
+ timeout
275
+ end
276
+ end
277
+
278
+ # @return [Boolean] Whether the `reserver` should be called.
279
+ def reserve_jobs_now?
280
+ next_poll_at = next_poll_scheduled_at
281
+ if next_poll_at && next_poll_at - Time.now <= 0.001
282
+ @next_poll_at.update { Time.now + polling_interval if polling_enabled? }
283
+ return true
284
+ end
285
+
286
+ next_job_at = next_job_scheduled_at
287
+ next_job_at && next_job_at <= Time.now
288
+ end
289
+
290
+ # @return [Float] Gets the polling interval jitter
291
+ def polling_interval_jitter
292
+ @interval_jitter
293
+ end
294
+
295
+ # Get the polling interval. If a jitter is configured, the interval is reduced or increased by `0.5 * jitter`.
296
+ # @return [Float] The amount of seconds before the next poll
297
+ def polling_interval
298
+ raise "Polling is disabled" unless @polling_interval.present?
299
+
300
+ @polling_interval + if polling_interval_jitter.zero?
301
+ 0
302
+ else
303
+ (Kernel.rand - 0.5) * polling_interval_jitter
304
+ end
305
+ end
306
+ end
307
+ end
308
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+ module Exekutor
3
+ # @private
4
+ module Internal
5
+ # Reserves jobs to be executed by the current worker
6
+ class Reserver
7
+ # The name to use for the SQL log message
8
+ ACTION_NAME = "Exekutor::Reserve"
9
+
10
+ # Creates a new Reserver
11
+ # @param worker_id [String] the id of the worker
12
+ # @param queues [Array<String>] the queues to watch
13
+ def initialize(worker_id, queues)
14
+ @worker_id = worker_id
15
+ @queue_filter_sql = build_queue_filter_sql(queues)
16
+ @json_serializer = Exekutor.config.load_json_serializer
17
+ end
18
+
19
+ # Reserves pending jobs
20
+ # @param limit [Integer] the number of jobs to reserve
21
+ # @return [Array<Job>,nil] the reserved jobs, or nil if no jobs were reserved
22
+ def reserve(limit)
23
+ return unless limit.positive?
24
+
25
+ results = Exekutor::Job.connection.exec_query <<~SQL, ACTION_NAME, [@worker_id, limit], prepare: true
26
+ UPDATE exekutor_jobs SET worker_id = $1, status = 'e' WHERE id IN (
27
+ SELECT id FROM exekutor_jobs
28
+ WHERE scheduled_at <= now() AND "status"='p' #{@queue_filter_sql}
29
+ ORDER BY priority, scheduled_at, enqueued_at
30
+ FOR UPDATE SKIP LOCKED
31
+ LIMIT $2
32
+ ) RETURNING "id", "payload", "options", "scheduled_at"
33
+ SQL
34
+ return unless results&.length&.positive?
35
+
36
+ parse_jobs results
37
+ end
38
+
39
+ def get_abandoned_jobs(active_job_ids)
40
+ jobs = Exekutor::Job.executing.where(worker_id: @worker_id)
41
+ jobs = jobs.where.not(id: active_job_ids) if active_job_ids.present?
42
+ attrs = %i[id payload options scheduled_at]
43
+ jobs.pluck(*attrs).map { |p| attrs.zip(p).to_h }
44
+ end
45
+
46
+ # Gets the earliest scheduled at of all pending jobs in the watched queues
47
+ # @return [Time,nil] The earliest scheduled at, or nil if the queues are empty
48
+ def earliest_scheduled_at
49
+ jobs = Exekutor::Job.pending
50
+ jobs.where! @queue_filter_sql.gsub(/^\s*AND\s+/, "") unless @queue_filter_sql.nil?
51
+ jobs.minimum(:scheduled_at)
52
+ end
53
+
54
+ private
55
+
56
+ # Parses jobs from the SQL results
57
+ def parse_jobs(sql_results)
58
+ sql_results.map do |result|
59
+ { id: result["id"],
60
+ payload: parse_json(result["payload"]),
61
+ options: parse_json(result["options"]),
62
+ scheduled_at: result['scheduled_at'] }
63
+ end
64
+ end
65
+
66
+ # Parses JSON using the configured serializer
67
+ def parse_json(str)
68
+ @json_serializer.load str unless str.nil?
69
+ end
70
+
71
+ # Builds SQL filter for the given queues
72
+ def build_queue_filter_sql(queues)
73
+ return nil if queues.nil? || (queues.is_a?(Array) && queues.empty?)
74
+ unless queues.is_a?(String) || queues.is_a?(Symbol) || queues.is_a?(Array)
75
+ raise ArgumentError, "queues must be nil, a String, Symbol, or an array of Strings or Symbols"
76
+ end
77
+
78
+ queues = queues.first if queues.is_a?(Array) && queues.one?
79
+ if queues.is_a? Array
80
+ unless queues.all? { |q| (q.is_a?(String) || q.is_a?(Symbol)) && !q.blank? }
81
+ raise ArgumentError, "queues contains an invalid value"
82
+ end
83
+
84
+ Exekutor::Job.sanitize_sql_for_conditions(["AND queue IN (?)", queues])
85
+ else
86
+ raise ArgumentError, "queue name cannot be empty" if queues.blank?
87
+
88
+ Exekutor::Job.sanitize_sql_for_conditions(["AND queue = ?", queues])
89
+ end
90
+ end
91
+
92
+ end
93
+ end
94
+ end
95
+
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+ module Exekutor
3
+ module Internal
4
+ # Serves a simple health check app
5
+ class StatusServer
6
+ include Internal::Logger
7
+ include Internal::Executable
8
+
9
+ DEFAULT_HANDLER = "webrick"
10
+
11
+ def initialize(worker:, pool:, port:, handler: DEFAULT_HANDLER, heartbeat_timeout: 30)
12
+ super()
13
+ @worker = worker
14
+ @pool = pool
15
+ @port = port
16
+ @handler = Rack::Handler.get(handler)
17
+ @heartbeat_timeout = heartbeat_timeout
18
+ @thread_running = Concurrent::AtomicBoolean.new false
19
+ @server = Concurrent::AtomicReference.new
20
+ end
21
+
22
+ def start
23
+ return false unless compare_and_set_state :pending, :started
24
+
25
+ start_thread
26
+ end
27
+
28
+ def running?
29
+ super && @thread_running.value
30
+ end
31
+
32
+ def stop
33
+ set_state :stopped
34
+ return unless @thread_running.value
35
+
36
+ server = @server.value
37
+ if server&.respond_to? :shutdown
38
+ server.shutdown
39
+ elsif server&.respond_to? :stop
40
+ server.stop
41
+ elsif server
42
+ Exekutor.say! "Cannot shutdown status server, #{server.class.name} does not respond to shutdown or stop"
43
+ end
44
+ end
45
+
46
+ protected
47
+
48
+ def run(worker, port)
49
+ return unless state == :started && @thread_running.make_true
50
+
51
+ Exekutor.say "Starting status server at 0.0.0.0:#{port}… (Timeout: #{@heartbeat_timeout} minutes)"
52
+ @handler.run(App.new(worker, @heartbeat_timeout), Port: port, Host: "0.0.0.0", Silent: true,
53
+ Logger: ::Logger.new(File.open(File::NULL, "w")), AccessLog: []) do |server|
54
+ @server.set server
55
+ end
56
+ rescue StandardError => err
57
+ Exekutor.on_fatal_error err, "[HealthServer] Runtime error!"
58
+ if running?
59
+ logger.info "Restarting in 10 seconds…"
60
+ Concurrent::ScheduledTask.execute(10.0, executor: @pool, &method(:start_thread))
61
+ end
62
+ ensure
63
+ @thread_running.make_false
64
+ end
65
+
66
+ # The Rack-app for the health-check server
67
+ class App
68
+
69
+ def initialize(worker, heartbeat_timeout)
70
+ @worker = worker
71
+ @heartbeat_timeout = heartbeat_timeout
72
+ end
73
+
74
+ def flatlined?
75
+ last_heartbeat = @worker.last_heartbeat
76
+ last_heartbeat.nil? || last_heartbeat < @heartbeat_timeout.minutes.ago
77
+ end
78
+
79
+ def call(env)
80
+ case Rack::Request.new(env).path
81
+ when "/"
82
+ [200, {}, [
83
+ <<~RESPONSE
84
+ [Exekutor]
85
+ - Use GET /ready to check whether the worker is running and connected to the DB
86
+ - Use GET /live to check whether the worker is running and is not hanging
87
+ - Use GET /threads to check thread usage
88
+ RESPONSE
89
+ ]]
90
+ when "/ready"
91
+ running = @worker.running?
92
+ if running
93
+ Exekutor::Job.connection_pool.with_connection do |connection|
94
+ running = connection.active?
95
+ end
96
+ end
97
+ running = false if running && flatlined?
98
+ [(running ? 200 : 503), { "Content-Type" => "text/plain" }, [
99
+ "#{running ? "[OK]" : "[Service unavailable]"} ID: #{@worker.id}; State: #{@worker.state}"
100
+ ]]
101
+ when "/live"
102
+ running = @worker.running?
103
+ last_heartbeat = if running
104
+ @worker.last_heartbeat
105
+ end
106
+ if running && (last_heartbeat.nil? || last_heartbeat < @heartbeat_timeout.minutes.ago)
107
+ running = false
108
+ end
109
+ [(running ? 200 : 503), { "Content-Type" => "text/plain" }, [
110
+ "#{running ? "[OK]" : "[Service unavailable]"} ID: #{@worker.id}; State: #{@worker.state}; Heartbeat: #{last_heartbeat&.iso8601 || "null"}"
111
+ ]]
112
+ when "/threads"
113
+ if @worker.running?
114
+ info = @worker.thread_stats
115
+ [(info ? 200 : 503), { "Content-Type" => "application/json" }, [info.to_json]]
116
+ else
117
+ [503, {"Content-Type" => "application/json"}, [{ error: "Worker not running" }.to_json]]
118
+ end
119
+ else
120
+ [404, {}, ["Not found"]]
121
+ end
122
+ end
123
+ end
124
+
125
+ private
126
+
127
+ def start_thread
128
+ @pool.post(@worker, @port, &method(:run)) if state == :started
129
+ end
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "internal/base_record"
4
+
5
+ module Exekutor
6
+ # Active record instance for a job
7
+ class Job < Internal::BaseRecord
8
+ self.implicit_order_column = :enqueued_at
9
+
10
+ belongs_to :worker, optional: true, class_name: "Info::Worker"
11
+ has_many :execution_errors, class_name: "JobError"
12
+
13
+ enum status: { pending: "p", executing: "e", completed: "c", failed: "f", discarded: "d" }
14
+
15
+ # Sets the status to pending and clears the assigned worker
16
+ def release!
17
+ update! status: "p", worker_id: nil
18
+ end
19
+
20
+ # Sets the status to pending, clears the assigned worker, and schedules execution at the indicated time.
21
+ # @param at [Time] when the job should be executed
22
+ def reschedule!(at: Time.current)
23
+ update! status: "p", scheduled_at: at, worker_id: nil, runtime: nil
24
+ end
25
+
26
+ # Sets the status to discarded.
27
+ def discard!
28
+ update! status: "d"
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "internal/base_record"
4
+
5
+ module Exekutor
6
+ # Active record instance for errors raised by jobs
7
+ class JobError < Internal::BaseRecord
8
+ self.implicit_order_column = :created_at
9
+ belongs_to :job
10
+ end
11
+ end
@@ -0,0 +1,95 @@
1
+ module Exekutor
2
+ # Mixin which defines custom job options for Exekutor. This module should be included in your job class.
3
+ # You can define the following options after including this module:
4
+ #
5
+ # ==== Queue timeout
6
+ # MyJob.set(queue_timeout: 1.hour).perform_later
7
+ # How long the job is allowed to be in the queue. If a job is not performed before this timeout, it will be discarded.
8
+ # The value should be a +ActiveSupport::Duration+.
9
+ #
10
+ # ==== Execution timeout
11
+ # MyJob.set(execution_timeout: 1.minute).perform_later
12
+ # How long the job is allowed to run. If a job is taking longer than this timeout, it will be killed and discarded.
13
+ # The value should be a +ActiveSupport::Duration+. Be aware that +Timeout::timeout+ is used internally for this, which
14
+ # can raise an error at any line of code in your application. <em>Use with caution</em>
15
+ #
16
+ # == Usage
17
+ # === +#set+
18
+ # You can specify the options per job when enqueueing the job using +#set+.
19
+ # MyJob.set(option_name: @option_value).perform_later
20
+ #
21
+ # === +#exekutor_options+
22
+ # You can also specify options that apply to all instances of a job by calling {#exekutor_options}.
23
+ # class MyOtherJob < ActiveJob::Base
24
+ # include Exekutor::JobOptions
25
+ # exekutor_options execution_timeout: 10.seconds
26
+ # end
27
+ #
28
+ # *NB* These options only work for jobs that are scheduled with +#perform_later+, the options are ignored when you
29
+ # perform the job immediately using +#perform_now+.
30
+ module JobOptions
31
+ extend ActiveSupport::Concern
32
+
33
+ # @private
34
+ VALID_EXEKUTOR_OPTIONS = %i[queue_timeout execution_timeout].freeze
35
+ private_constant "VALID_EXEKUTOR_OPTIONS"
36
+
37
+ # @return [Hash<Symbol, Object>] the exekutor options for this job
38
+ attr_reader :exekutor_options
39
+
40
+ # @!visibility private
41
+ def enqueue(options = {})
42
+ # :nodoc:
43
+ @exekutor_options = self.class.exekutor_job_options || {}
44
+ job_options = options&.slice(*VALID_EXEKUTOR_OPTIONS)
45
+ if job_options
46
+ self.class.validate_exekutor_options! job_options
47
+ @exekutor_options = @exekutor_options.merge job_options
48
+ end
49
+ super(options)
50
+ end
51
+
52
+ class_methods do
53
+ # Sets the exekutor options that apply to all instances of this job. These options can be overwritten with +#set+.
54
+ # @param options [Hash<Symbol, Object>] the exekutor options
55
+ # @option options [ActiveSupport::Duration] :queue_timeout The queue timeout
56
+ # @option options [ActiveSupport::Duration] :execution_timeout The execution timeout
57
+ # @return [void]
58
+ def exekutor_options(options)
59
+ validate_exekutor_options! options
60
+ @exekutor_job_options = options
61
+ end
62
+
63
+ # Gets the exekutor options that apply to all instances of this job. These options may be overwritten by +#set+.
64
+ # @return [Hash<Symbol, Object>] the options
65
+ def exekutor_job_options
66
+ @exekutor_job_options if defined?(@exekutor_job_options)
67
+ end
68
+
69
+ # Validates the exekutor job options passed to {#exekutor_options} and +#set+
70
+ # @param options [Hash<Symbol, Object>] the options to validate
71
+ # @raise [InvalidOption] if any of the options are invalid
72
+ # @private
73
+ # @return [void]
74
+ def validate_exekutor_options!(options)
75
+ return unless options.present?
76
+
77
+ invalid_options = options.keys - VALID_EXEKUTOR_OPTIONS
78
+ if invalid_options.present?
79
+ raise InvalidOption, "Invalid option#{"s" if invalid_options.many?}: " \
80
+ "#{invalid_options.map(&:inspect).join(", ")}. " \
81
+ "Valid options are: #{VALID_EXEKUTOR_OPTIONS.map(&:inspect).join(", ")}"
82
+ end
83
+ if options[:queue_timeout]
84
+ raise InvalidOption, ":queue_timeout must be an interval" unless options[:queue_timeout].is_a? ActiveSupport::Duration
85
+ end
86
+ if options[:execution_timeout]
87
+ raise InvalidOption, ":execution_timeout must be an interval" unless options[:execution_timeout].is_a? ActiveSupport::Duration
88
+ end
89
+ end
90
+ end
91
+
92
+ # Raised when invalid options are given
93
+ class InvalidOption < ::Exekutor::Error; end
94
+ end
95
+ end