exekutor 0.1.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.
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