exekutor 0.1.0 → 0.1.2

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 (51) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +2 -3
  3. data/exe/exekutor +2 -2
  4. data/lib/active_job/queue_adapters/exekutor_adapter.rb +2 -1
  5. data/lib/exekutor/asynchronous.rb +143 -75
  6. data/lib/exekutor/cleanup.rb +27 -28
  7. data/lib/exekutor/configuration.rb +102 -48
  8. data/lib/exekutor/hook.rb +15 -11
  9. data/lib/exekutor/info/worker.rb +3 -3
  10. data/lib/exekutor/internal/base_record.rb +2 -1
  11. data/lib/exekutor/internal/callbacks.rb +55 -35
  12. data/lib/exekutor/internal/cli/app.rb +33 -23
  13. data/lib/exekutor/internal/cli/application_loader.rb +17 -6
  14. data/lib/exekutor/internal/cli/cleanup.rb +54 -40
  15. data/lib/exekutor/internal/cli/daemon.rb +9 -11
  16. data/lib/exekutor/internal/cli/default_option_value.rb +3 -1
  17. data/lib/exekutor/internal/cli/info.rb +117 -84
  18. data/lib/exekutor/internal/cli/manager.rb +234 -123
  19. data/lib/exekutor/internal/configuration_builder.rb +49 -30
  20. data/lib/exekutor/internal/database_connection.rb +6 -0
  21. data/lib/exekutor/internal/executable.rb +12 -7
  22. data/lib/exekutor/internal/executor.rb +50 -21
  23. data/lib/exekutor/internal/hooks.rb +11 -8
  24. data/lib/exekutor/internal/listener.rb +85 -43
  25. data/lib/exekutor/internal/logger.rb +29 -10
  26. data/lib/exekutor/internal/provider.rb +96 -77
  27. data/lib/exekutor/internal/reserver.rb +66 -19
  28. data/lib/exekutor/internal/status_server.rb +87 -54
  29. data/lib/exekutor/job.rb +1 -1
  30. data/lib/exekutor/job_error.rb +1 -1
  31. data/lib/exekutor/job_options.rb +22 -13
  32. data/lib/exekutor/plugins/appsignal.rb +7 -5
  33. data/lib/exekutor/plugins.rb +8 -4
  34. data/lib/exekutor/queue.rb +69 -30
  35. data/lib/exekutor/version.rb +1 -1
  36. data/lib/exekutor/worker.rb +89 -48
  37. data/lib/exekutor.rb +2 -2
  38. data/lib/generators/exekutor/configuration_generator.rb +11 -6
  39. data/lib/generators/exekutor/install_generator.rb +24 -15
  40. data/lib/generators/exekutor/templates/install/functions/exekutor_broadcast_job_enqueued.sql +10 -0
  41. data/lib/generators/exekutor/templates/install/functions/exekutor_requeue_orphaned_jobs.sql +11 -0
  42. data/lib/generators/exekutor/templates/install/migrations/create_exekutor_schema.rb.erb +23 -22
  43. data/lib/generators/exekutor/templates/install/triggers/exekutor_broadcast_job_enqueued.sql +7 -0
  44. data/lib/generators/exekutor/templates/install/triggers/exekutor_requeue_orphaned_jobs.sql +5 -0
  45. data.tar.gz.sig +0 -0
  46. metadata +67 -23
  47. metadata.gz.sig +0 -0
  48. data/lib/generators/exekutor/templates/install/functions/job_notifier.sql +0 -7
  49. data/lib/generators/exekutor/templates/install/functions/requeue_orphaned_jobs.sql +0 -7
  50. data/lib/generators/exekutor/templates/install/triggers/notify_workers.sql +0 -6
  51. data/lib/generators/exekutor/templates/install/triggers/requeue_orphaned_jobs.sql +0 -5
@@ -1,14 +1,29 @@
1
1
  # frozen_string_literal: true
2
+
2
3
  module Exekutor
3
4
  module Internal
4
- # Serves a simple health check app
5
+ # Serves a simple health check app. The app provides 4 endpoints:
6
+ # - +/+, which lists the other endpoints;
7
+ # - +/ready+, which indicates whether the worker is ready to start work;
8
+ # - +/live+, which indicates whether the worker is ready and whether the worker is still alive;
9
+ # - +/threads+, which indicated the thread usage of the worker.
10
+ #
11
+ # Please note that this server uses +webrick+ by default, which is no longer a default gem from ruby 3.0 onwards.
12
+ #
13
+ # === Example requests
14
+ # $ curl localhost:9000/ready
15
+ # [OK] ID: f1a2ee6a-cdac-459c-a4b8-de7c6a8bbae6; State: started
16
+ # $ curl localhost:9000/live
17
+ # [OK] ID: f1a2ee6a-cdac-459c-a4b8-de7c6a8bbae6; State: started; Heartbeat: 2023-04-05T16:27:00Z
18
+ # $ curl localhost:9000/threads
19
+ # {"minimum":1,"maximum":10,"available":4,"usage_percent":60.0}
5
20
  class StatusServer
6
21
  include Internal::Logger
7
22
  include Internal::Executable
8
23
 
9
24
  DEFAULT_HANDLER = "webrick"
10
25
 
11
- def initialize(worker:, pool:, port:, handler: DEFAULT_HANDLER, heartbeat_timeout: 30)
26
+ def initialize(worker:, pool:, port:, handler: DEFAULT_HANDLER, heartbeat_timeout: 30.minutes)
12
27
  super()
13
28
  @worker = worker
14
29
  @pool = pool
@@ -19,45 +34,50 @@ module Exekutor
19
34
  @server = Concurrent::AtomicReference.new
20
35
  end
21
36
 
37
+ # Starts the web server
22
38
  def start
23
39
  return false unless compare_and_set_state :pending, :started
24
40
 
25
41
  start_thread
26
42
  end
27
43
 
44
+ # @return [Boolean] whether the web server is active
28
45
  def running?
29
46
  super && @thread_running.value
30
47
  end
31
48
 
49
+ # Stops the web server
32
50
  def stop
33
- set_state :stopped
51
+ self.state = :stopped
34
52
  return unless @thread_running.value
35
53
 
36
54
  server = @server.value
37
- if server&.respond_to? :shutdown
38
- server.shutdown
39
- elsif server&.respond_to? :stop
40
- server.stop
55
+ shutdown_method = %i[shutdown stop].find { |method| server.respond_to? method }
56
+ if shutdown_method
57
+ server.send(shutdown_method)
58
+ Exekutor.say "Status server stopped"
41
59
  elsif server
42
- Exekutor.say! "Cannot shutdown status server, #{server.class.name} does not respond to shutdown or stop"
60
+ Exekutor.print_error "Cannot shutdown status server, " \
61
+ "#{server.class.name} does not respond to `shutdown` or `stop`"
43
62
  end
44
63
  end
45
64
 
46
65
  protected
47
66
 
67
+ # Runs the web server, should be called from a separate thread
48
68
  def run(worker, port)
49
69
  return unless state == :started && @thread_running.make_true
50
70
 
51
- Exekutor.say "Starting status server at 0.0.0.0:#{port}… (Timeout: #{@heartbeat_timeout} minutes)"
71
+ Exekutor.say "Starting status server at 0.0.0.0:#{port}… (Timeout: #{@heartbeat_timeout.inspect})"
52
72
  @handler.run(App.new(worker, @heartbeat_timeout), Port: port, Host: "0.0.0.0", Silent: true,
53
73
  Logger: ::Logger.new(File.open(File::NULL, "w")), AccessLog: []) do |server|
54
74
  @server.set server
55
75
  end
56
- rescue StandardError => err
57
- Exekutor.on_fatal_error err, "[HealthServer] Runtime error!"
76
+ rescue StandardError => e
77
+ Exekutor.on_fatal_error e, "[Status server] Runtime error!"
58
78
  if running?
59
79
  logger.info "Restarting in 10 seconds…"
60
- Concurrent::ScheduledTask.execute(10.0, executor: @pool, &method(:start_thread))
80
+ Concurrent::ScheduledTask.execute(10.0, executor: @pool) { start_thread }
61
81
  end
62
82
  ensure
63
83
  @thread_running.make_false
@@ -65,68 +85,81 @@ module Exekutor
65
85
 
66
86
  # The Rack-app for the health-check server
67
87
  class App
68
-
69
88
  def initialize(worker, heartbeat_timeout)
70
89
  @worker = worker
71
90
  @heartbeat_timeout = heartbeat_timeout
72
91
  end
73
92
 
74
- def flatlined?
75
- last_heartbeat = @worker.last_heartbeat
76
- last_heartbeat.nil? || last_heartbeat < @heartbeat_timeout.minutes.ago
77
- end
78
-
79
93
  def call(env)
80
94
  case Rack::Request.new(env).path
81
95
  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
- ]]
96
+ render_root
90
97
  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
- ]]
98
+ render_ready
101
99
  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
- ]]
100
+ render_live
112
101
  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
102
+ render_threads
119
103
  else
120
104
  [404, {}, ["Not found"]]
121
105
  end
122
106
  end
107
+
108
+ private
109
+
110
+ def flatlined?(last_heartbeat = @worker.last_heartbeat)
111
+ last_heartbeat.nil? || last_heartbeat < @heartbeat_timeout.ago
112
+ end
113
+
114
+ def render_threads
115
+ if @worker.running?
116
+ info = @worker.thread_stats
117
+ [(info ? 200 : 503), { "Content-Type" => "application/json" }, [info.to_json]]
118
+ else
119
+ [503, { "Content-Type" => "application/json" }, [{ error: "Worker not running" }.to_json]]
120
+ end
121
+ end
122
+
123
+ def render_live
124
+ running = @worker.running?
125
+ last_heartbeat = (@worker.last_heartbeat if running)
126
+ running = false if flatlined?(last_heartbeat)
127
+ [(running ? 200 : 503), { "Content-Type" => "text/plain" }, [
128
+ "#{running ? "[OK]" : "[Service unavailable]"} ID: #{@worker.id}; State: #{@worker.state}; " \
129
+ "Heartbeat: #{last_heartbeat&.iso8601 || "null"}"
130
+ ]]
131
+ end
132
+
133
+ def render_ready
134
+ running = @worker.running?
135
+ if running
136
+ Exekutor::Job.connection_pool.with_connection do |connection|
137
+ running = connection.active?
138
+ end
139
+ end
140
+ running = false if running && flatlined?
141
+ [(running ? 200 : 503), { "Content-Type" => "text/plain" }, [
142
+ "#{running ? "[OK]" : "[Service unavailable]"} ID: #{@worker.id}; State: #{@worker.state}"
143
+ ]]
144
+ end
145
+
146
+ def render_root
147
+ [200, {}, [
148
+ <<~RESPONSE
149
+ [Exekutor]
150
+ - Use GET /ready to check whether the worker is running and connected to the DB
151
+ - Use GET /live to check whether the worker is running and is not hanging
152
+ - Use GET /threads to check thread usage
153
+ RESPONSE
154
+ ]]
155
+ end
123
156
  end
124
157
 
125
158
  private
126
159
 
127
160
  def start_thread
128
- @pool.post(@worker, @port, &method(:run)) if state == :started
161
+ @pool.post(@worker, @port) { |*args| run(*args) } if state == :started
129
162
  end
130
163
  end
131
164
  end
132
- end
165
+ end
data/lib/exekutor/job.rb CHANGED
@@ -28,4 +28,4 @@ module Exekutor
28
28
  update! status: "d"
29
29
  end
30
30
  end
31
- end
31
+ end
@@ -8,4 +8,4 @@ module Exekutor
8
8
  self.implicit_order_column = :created_at
9
9
  belongs_to :job
10
10
  end
11
- end
11
+ end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Exekutor
2
4
  # Mixin which defines custom job options for Exekutor. This module should be included in your job class.
3
5
  # You can define the following options after including this module:
@@ -32,7 +34,7 @@ module Exekutor
32
34
 
33
35
  # @private
34
36
  VALID_EXEKUTOR_OPTIONS = %i[queue_timeout execution_timeout].freeze
35
- private_constant "VALID_EXEKUTOR_OPTIONS"
37
+ private_constant :VALID_EXEKUTOR_OPTIONS
36
38
 
37
39
  # @return [Hash<Symbol, Object>] the exekutor options for this job
38
40
  attr_reader :exekutor_options
@@ -67,28 +69,35 @@ module Exekutor
67
69
  end
68
70
 
69
71
  # Validates the exekutor job options passed to {#exekutor_options} and +#set+
70
- # @param options [Hash<Symbol, Object>] the options to validate
72
+ # @param options [Hash<Symbol, Any>] the options to validate
71
73
  # @raise [InvalidOption] if any of the options are invalid
72
74
  # @private
73
75
  # @return [void]
74
76
  def validate_exekutor_options!(options)
75
- return unless options.present?
77
+ return true if options.blank?
76
78
 
77
- invalid_options = options.keys - VALID_EXEKUTOR_OPTIONS
78
- if invalid_options.present?
79
+ if (invalid_options = options.keys - VALID_EXEKUTOR_OPTIONS).present?
79
80
  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
81
+ "#{invalid_options.map(&:inspect).join(", ")}. " \
82
+ "Valid options are: #{VALID_EXEKUTOR_OPTIONS.map(&:inspect).join(", ")}"
88
83
  end
84
+ JobOptions.validate_option_type! options, :queue_timeout, ::ActiveSupport::Duration
85
+ JobOptions.validate_option_type! options, :execution_timeout, ::ActiveSupport::Duration
86
+ true
89
87
  end
90
88
  end
91
89
 
90
+ # Validates the type of an option
91
+ # @param options [Hash<Symbol, Any>] the options
92
+ # @param name [Symbol] the name of the option to validate
93
+ # @param valid_type [Class] the valid type for the value
94
+ # @raise [InvalidOption] if the configured value is not an instance of +valid_type+
95
+ def self.validate_option_type!(options, name, valid_type)
96
+ return if options[name].nil? || options[name].is_a?(valid_type)
97
+
98
+ raise InvalidOption, ":#{name} must be an instance of #{valid_type.name} (given: #{options[name].class.name})"
99
+ end
100
+
92
101
  # Raised when invalid options are given
93
102
  class InvalidOption < ::Exekutor::Error; end
94
103
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  raise Exekutor::Plugins::LoadError, "Appsignal not found, is the gem loaded?" unless defined? Appsignal
2
4
 
3
5
  module Exekutor
@@ -21,14 +23,14 @@ module Exekutor
21
23
 
22
24
  ::Appsignal.monitor_transaction(
23
25
  "perform_job.exekutor",
24
- class: payload['job_class'],
26
+ class: payload["job_class"],
25
27
  method: "perform",
26
28
  params: params,
27
29
  metadata: {
28
- id: payload['job_id'],
29
- queue: payload['queue_name'],
30
- priority: payload.fetch('priority', Exekutor.config.default_queue_priority),
31
- attempts: payload.fetch('attempts', 0)
30
+ id: payload["job_id"],
31
+ queue: payload["queue_name"],
32
+ priority: payload.fetch("priority", Exekutor.config.default_queue_priority),
33
+ attempts: payload.fetch("attempts", 0)
32
34
  },
33
35
  queue_start: job[:scheduled_at]
34
36
  ) do
@@ -1,13 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The Exekutor namespace
1
4
  module Exekutor
2
5
  module Plugins
6
+ # Raised when a plugin cannot be loaded
3
7
  class LoadError < ::LoadError; end
4
8
  end
5
9
 
6
10
  def self.load_plugin(name)
7
- if File.exist? File.join(__dir__, "plugins/#{name}.rb")
8
- require_relative "plugins/#{name}"
9
- else
11
+ unless File.exist? File.join(__dir__, "plugins/#{name}.rb")
10
12
  raise Plugins::LoadError, "The #{name} plugin does not exist. Have you spelled it correctly?"
11
13
  end
14
+
15
+ require_relative "plugins/#{name}"
12
16
  end
13
- end
17
+ end
@@ -6,7 +6,7 @@ module Exekutor
6
6
  # Used when logging the SQL queries
7
7
  # @private
8
8
  ACTION_NAME = "Exekutor::Enqueue"
9
- private_constant "ACTION_NAME"
9
+ private_constant :ACTION_NAME
10
10
 
11
11
  # Valid range for job priority
12
12
  # @private
@@ -17,18 +17,18 @@ module Exekutor
17
17
  MAX_NAME_LENGTH = 63
18
18
 
19
19
  # Adds a job to the queue, scheduled to perform immediately
20
- # @param jobs [Array<ActiveJob::Base>] the jobs to enqueue
21
- # @return [void]
22
- def push(*jobs)
23
- create_records(jobs)
20
+ # @param jobs [ActiveJob::Base,Array<ActiveJob::Base>] the jobs to enqueue
21
+ # @return [Integer] the number of enqueued jobs
22
+ def push(jobs)
23
+ create_records Array.wrap(jobs)
24
24
  end
25
25
 
26
26
  # Adds a job to the queue, scheduled to be performed at the indicated time
27
- # @param jobs [Array<ActiveJob::Base>] the jobs to enqueue
27
+ # @param jobs [ActiveJob::Base,Array<ActiveJob::Base>] the jobs to enqueue
28
28
  # @param timestamp [Time,Date,Integer,Float] when the job should be performed
29
- # @return [void]
30
- def schedule_at(*jobs, timestamp)
31
- create_records(jobs, scheduled_at: timestamp)
29
+ # @return [Integer] the number of enqueued jobs
30
+ def schedule_at(jobs, timestamp)
31
+ create_records(Array.wrap(jobs), scheduled_at: timestamp)
32
32
  end
33
33
 
34
34
  private
@@ -38,45 +38,83 @@ module Exekutor
38
38
  # @param scheduled_at [Time,Date,Integer,Float] when the job should be performed
39
39
  # @return [void]
40
40
  def create_records(jobs, scheduled_at: nil)
41
- unless jobs.is_a?(Array) && jobs.all? { |job| job.is_a?(ActiveJob::Base) }
42
- raise ArgumentError, "jobs must be an array with ActiveJob items"
41
+ raise ArgumentError, "jobs must be an array with ActiveJob items (Actual: #{jobs.class})" unless jobs.is_a?(Array)
42
+
43
+ unless jobs.all?(ActiveJob::Base)
44
+ raise ArgumentError, "jobs must be an array with ActiveJob items (Found: #{
45
+ jobs.map { |job| job.class unless job.is_a? ActiveJob::Base }.compact.join(", ")
46
+ })"
43
47
  end
44
48
 
49
+ scheduled_at = parse_scheduled_at(scheduled_at)
50
+ json_serializer = Exekutor.config.load_json_serializer
51
+
52
+ inserted_records = nil
53
+ Internal::Hooks.run :enqueue, jobs do
54
+ inserted_records = insert_job_records(jobs, scheduled_at, json_serializer)
55
+ end
56
+ inserted_records
57
+ end
58
+
59
+ # Converts the given value to an epoch timestamp. Returns the current epoch timestamp if the given value is nil
60
+ # @param scheduled_at [nil,Numeric,Time,Date] The timestamp to convert to an epoch timestamp
61
+ # @return [Float,Integer] The epoch equivalent of +scheduled_at+
62
+ def parse_scheduled_at(scheduled_at)
45
63
  if scheduled_at.nil?
46
- scheduled_at = Time.now.to_i
64
+ Time.now.to_i
47
65
  else
48
66
  case scheduled_at
49
67
  when Integer, Float
50
68
  raise ArgumentError, "scheduled_at must be a valid epoch" unless scheduled_at.positive?
69
+
70
+ scheduled_at
51
71
  when Time
52
- scheduled_at = scheduled_at.to_f
72
+ scheduled_at.to_f
53
73
  when Date
54
- scheduled_at = scheduled_at.at_beginning_of_day.to_f
74
+ scheduled_at.at_beginning_of_day.to_f
55
75
  else
56
76
  raise ArgumentError, "scheduled_at must be an epoch, time, or date"
57
77
  end
58
78
  end
79
+ end
59
80
 
60
- json_serializer = Exekutor.config.load_json_serializer
61
-
62
- Internal::Hooks.run :enqueue, jobs do
63
- if jobs.one?
64
- Exekutor::Job.connection.exec_query <<~SQL, ACTION_NAME, job_sql_binds(jobs.first, scheduled_at, json_serializer), prepare: true
65
- INSERT INTO exekutor_jobs ("queue", "priority", "scheduled_at", "active_job_id", "payload", "options") VALUES ($1, $2, to_timestamp($3), $4, $5, $6) RETURNING id;
66
- SQL
67
- else
68
- insert_statements = jobs.map do |job|
69
- Exekutor::Job.sanitize_sql_for_assignment(
70
- ["(?, ?, to_timestamp(?), ?, ?::jsonb, ?::jsonb)", *job_sql_binds(job, scheduled_at, json_serializer)]
71
- )
72
- end
73
- Exekutor::Job.connection.insert <<~SQL, ACTION_NAME
81
+ # Fires off an INSERT INTO query for the given jobs
82
+ # @param jobs [Array<ActiveJob::Base>] the jobs to insert
83
+ # @param scheduled_at [Integer,Float] the scheduled execution time for the jobs as an epoch timestamp
84
+ # @param json_serializer [#dump] the serializer to use to convert hashes into JSON
85
+ def insert_job_records(jobs, scheduled_at, json_serializer)
86
+ if jobs.one?
87
+ insert_singular_job(jobs.first, json_serializer, scheduled_at)
88
+ else
89
+ insert_statements = jobs.map do |job|
90
+ Exekutor::Job.sanitize_sql_for_assignment(
91
+ ["(?, ?, to_timestamp(?), ?, ?::jsonb, ?::jsonb)", *job_sql_binds(job, scheduled_at, json_serializer)]
92
+ )
93
+ end
94
+ begin
95
+ pg_result = Exekutor::Job.connection.execute <<~SQL, ACTION_NAME
74
96
  INSERT INTO exekutor_jobs ("queue", "priority", "scheduled_at", "active_job_id", "payload", "options") VALUES #{insert_statements.join(",")}
75
97
  SQL
98
+ inserted_records = pg_result.cmd_tuples
99
+ ensure
100
+ pg_result.clear
76
101
  end
102
+ inserted_records
77
103
  end
78
104
  end
79
105
 
106
+ # Fires off an INSERT INTO query for the given job using a prepared statement
107
+ # @param job [ActiveJob::Base] the job to insert
108
+ # @param scheduled_at [Integer,Float] the scheduled execution time for the jobs as an epoch timestamp
109
+ # @param json_serializer [#dump] the serializer to use to convert hashes into JSON
110
+ def insert_singular_job(job, json_serializer, scheduled_at)
111
+ sql_binds = job_sql_binds(job, scheduled_at, json_serializer)
112
+ ar_result = Exekutor::Job.connection.exec_query <<~SQL, ACTION_NAME, sql_binds, prepare: true
113
+ INSERT INTO exekutor_jobs ("queue", "priority", "scheduled_at", "active_job_id", "payload", "options") VALUES ($1, $2, to_timestamp($3), $4, $5, $6) RETURNING id;
114
+ SQL
115
+ ar_result.length
116
+ end
117
+
80
118
  # Converts the specified job to SQL bind parameters to insert it into the database
81
119
  # @param job [ActiveJob::Base] the job to insert
82
120
  # @param scheduled_at [Float] the epoch timestamp for when the job should be executed
@@ -86,7 +124,8 @@ module Exekutor
86
124
  if job.queue_name.blank?
87
125
  raise Error, "The queue must be set"
88
126
  elsif job.queue_name && job.queue_name.length > Queue::MAX_NAME_LENGTH
89
- raise Error, "The queue name \"#{job.queue_name}\" is too long, the limit is #{Queue::MAX_NAME_LENGTH} characters"
127
+ raise Error,
128
+ "The queue name \"#{job.queue_name}\" is too long, the limit is #{Queue::MAX_NAME_LENGTH} characters"
90
129
  end
91
130
 
92
131
  options = exekutor_options job
@@ -106,7 +145,7 @@ module Exekutor
106
145
  def exekutor_options(job)
107
146
  return nil unless job.respond_to?(:exekutor_options)
108
147
 
109
- options = job.exekutor_options.stringify_keys
148
+ options = job.exekutor_options&.stringify_keys
110
149
  if options && options["queue_timeout"]
111
150
  options["start_execution_before"] = Time.now.to_f + options.delete("queue_timeout").to_f
112
151
  end
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Exekutor
4
4
  # The current version of Exekutor
5
- VERSION = "0.1.0"
5
+ VERSION = "0.1.2"
6
6
  end