inst-jobs 0.12.3 → 0.13.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
  SHA1:
3
- metadata.gz: fb490099d8b25310bcfd0c7f419dd095277f9d11
4
- data.tar.gz: 2be27c6ea2e6d37b6739d6ef19dcf971a0bcf81e
3
+ metadata.gz: 59c2643528ac5d09160a35e1d4fd338d058b593d
4
+ data.tar.gz: 69e5d68901df56c91fc10df1eaafb973877ef9fa
5
5
  SHA512:
6
- metadata.gz: 935a70be1f89625da0a07d96a02cbc4b21bfcd414d93e260582c266a987be59f947ff7084fa723ff8edebbca6262a4619e41ca88cac95c125b4c81c289e8cf5c
7
- data.tar.gz: 4930328d5b9c06f595425f11ae11c03113a4cfaa96327062e6c085e4da354d86c4bc1fcd4f54bbbe983e1213acf4d85fd14aaee5ce37f826ce7b22837f95b594
6
+ metadata.gz: e9b5d4be7a15c770a825beffc9d175a0f6b6318e6df899af14995f90f52dc24f23adc7b4f90321e8cb9eba2818ed0ac7889394d382ce86b18fe58d61aef91f60
7
+ data.tar.gz: 31d950b9b5bc5d843f9ed04f316f0ce076b3ba4e32f5284c499a1ed1cd4d4ab36d674246fdb31fdefde8d25b02a42e84e3568f5af567fb35637250a91e37f1c3
@@ -16,6 +16,13 @@ module Delayed
16
16
  class Job < ::ActiveRecord::Base
17
17
  include Delayed::Backend::Base
18
18
  self.table_name = :delayed_jobs
19
+ # Rails hasn't completely loaded yet, and setting the table name will cache some stuff
20
+ # so reset that cache so that it will load correctly after Rails is all loaded
21
+ # It's fixed in Rails 5 to not cache anything when you set the table_name
22
+ if Rails.version < '5' && Rails.version >= '4.2'
23
+ @arel_engine = nil
24
+ @arel_table = nil
25
+ end
19
26
 
20
27
  def self.reconnect!
21
28
  clear_all_connections!
@@ -201,7 +208,9 @@ module Delayed
201
208
  def self.get_and_lock_next_available(worker_names,
202
209
  queue = Delayed::Settings.queue,
203
210
  min_priority = nil,
204
- max_priority = nil)
211
+ max_priority = nil,
212
+ extra_jobs: 0,
213
+ extra_jobs_owner: nil)
205
214
 
206
215
  check_queue(queue)
207
216
  check_priorities(min_priority, max_priority)
@@ -216,7 +225,7 @@ module Delayed
216
225
  effective_worker_names = Array(worker_names)
217
226
 
218
227
  target_jobs = all_available(queue, min_priority, max_priority).
219
- limit(effective_worker_names.length).
228
+ limit(effective_worker_names.length + extra_jobs).
220
229
  lock
221
230
  jobs_with_row_number = all.from(target_jobs).
222
231
  select("id, ROW_NUMBER() OVER () AS row_number")
@@ -224,6 +233,9 @@ module Delayed
224
233
  effective_worker_names.each_with_index do |worker, i|
225
234
  updates << "WHEN #{i + 1} THEN #{connection.quote(worker)} "
226
235
  end
236
+ if extra_jobs_owner
237
+ updates << "ELSE #{connection.quote(extra_jobs_owner)} "
238
+ end
227
239
  updates << "END, locked_at = #{connection.quote(db_time_now)}"
228
240
  # joins and returning in an update! just bypass AR
229
241
  query = "UPDATE #{quoted_table_name} SET #{updates} FROM (#{jobs_with_row_number.to_sql}) j2 WHERE j2.id=delayed_jobs.id RETURNING delayed_jobs.*"
@@ -231,7 +243,14 @@ module Delayed
231
243
  # because this is an atomic query, we don't have to return more jobs than we needed
232
244
  # to try and lock them, nor is there a possibility we need to try again because
233
245
  # all of the jobs we tried to lock had already been locked by someone else
234
- return worker_names.is_a?(Array) ? jobs.index_by(&:locked_by) : jobs.first
246
+ if worker_names.is_a?(Array)
247
+ result = jobs.index_by(&:locked_by)
248
+ # all of the extras can come back as an array
249
+ result[extra_jobs_owner] = jobs.select { |j| j.locked_by == extra_jobs_owner } if extra_jobs_owner
250
+ return result
251
+ else
252
+ return jobs.first
253
+ end
235
254
  else
236
255
  batch_size = Settings.fetch_batch_size
237
256
  batch_size *= worker_names.length if worker_names.is_a?(Array)
@@ -314,6 +333,12 @@ module Delayed
314
333
  end
315
334
  end
316
335
 
336
+ def self.unlock(jobs)
337
+ unlocked = where(id: jobs).update_all(locked_at: nil, locked_by: nil)
338
+ jobs.each(&:unlock)
339
+ unlocked
340
+ end
341
+
317
342
  # Lock this job for this worker.
318
343
  # Returns true if we have the lock, false otherwise.
319
344
  #
@@ -332,6 +357,18 @@ module Delayed
332
357
  end
333
358
  end
334
359
 
360
+ def transfer_lock!(from:, to:)
361
+ now = self.class.db_time_now
362
+ # We don't own this job so we will update the locked_by name and the locked_at
363
+ affected_rows = self.class.where(id: self, locked_by: from).update_all(locked_at: now, locked_by: to)
364
+ if affected_rows == 1
365
+ mark_as_locked!(now, to)
366
+ return true
367
+ else
368
+ return false
369
+ end
370
+ end
371
+
335
372
  def mark_as_locked!(time, worker)
336
373
  self.locked_at = time
337
374
  self.locked_by = worker
@@ -118,6 +118,13 @@ module Delayed
118
118
  Time.now.utc
119
119
  end
120
120
 
121
+ def unlock_orphaned_pending_jobs
122
+ horizon = db_time_now - Settings.parent_process[:pending_jobs_idle_timeout] * 4
123
+ orphaned_jobs = running_jobs.select { |job| job.locked_by.start_with?('work_queue:') && job.locked_at < horizon }
124
+ return 0 if orphaned_jobs.empty?
125
+ unlock(orphaned_jobs)
126
+ end
127
+
121
128
  def unlock_orphaned_jobs(pid = nil, name = nil)
122
129
  begin
123
130
  name ||= Socket.gethostname
@@ -221,7 +221,9 @@ class Job
221
221
  def self.get_and_lock_next_available(worker_name,
222
222
  queue = Delayed::Settings.queue,
223
223
  min_priority = Delayed::MIN_PRIORITY,
224
- max_priority = Delayed::MAX_PRIORITY)
224
+ max_priority = Delayed::MAX_PRIORITY,
225
+ extra_jobs: nil,
226
+ extra_jobs_owner: nil)
225
227
 
226
228
  check_queue(queue)
227
229
  check_priorities(min_priority, max_priority)
@@ -352,9 +354,18 @@ class Job
352
354
  self.create!(options.merge(:singleton => true))
353
355
  end
354
356
 
357
+ def self.unlock(jobs)
358
+ jobs.each(&:unlock!)
359
+ jobs.length
360
+ end
361
+
355
362
  # not saved, just used as a marker when creating
356
363
  attr_accessor :singleton
357
364
 
365
+ def transfer_lock!(from:, to:)
366
+ lock_in_redis!(to)
367
+ end
368
+
358
369
  def lock_in_redis!(worker_name)
359
370
  self.locked_at = self.class.db_time_now
360
371
  self.locked_by = worker_name
@@ -362,8 +373,7 @@ class Job
362
373
  end
363
374
 
364
375
  def unlock!
365
- self.locked_at = nil
366
- self.locked_by = nil
376
+ unlock
367
377
  save!
368
378
  end
369
379
 
@@ -0,0 +1,32 @@
1
+ require 'date'
2
+
3
+ module Delayed
4
+ module Logging
5
+ TIMESTAMP_FORMAT = '%Y-%m-%dT%H:%M:%S.%6N'.freeze
6
+ private_constant :TIMESTAMP_FORMAT
7
+
8
+ FORMAT = '%s - %s'
9
+ private_constant :FORMAT
10
+
11
+
12
+ def self.logger
13
+ return @logger if @logger
14
+ if defined?(Rails.logger) && Rails.logger
15
+ @logger = Rails.logger
16
+ else
17
+ @logger = ::Logger.new(STDOUT).tap do |logger|
18
+ logger.formatter = ->(_, time, _, msg) {
19
+ FORMAT % [
20
+ time.strftime(TIMESTAMP_FORMAT),
21
+ msg
22
+ ]
23
+ }
24
+ end
25
+ end
26
+ end
27
+
28
+ def logger
29
+ Delayed::Logging.logger
30
+ end
31
+ end
32
+ end
@@ -1,5 +1,6 @@
1
1
  require 'yaml'
2
2
  require 'erb'
3
+ require 'active_support/core_ext/hash/indifferent_access'
3
4
 
4
5
  module Delayed
5
6
  module Settings
@@ -17,7 +18,6 @@ module Delayed
17
18
  :disable_periodic_jobs,
18
19
  :disable_automatic_orphan_unlocking,
19
20
  :last_ditch_logfile,
20
- :parent_process_client_timeout,
21
21
  ]
22
22
  SETTINGS_WITH_ARGS = [ :num_strands ]
23
23
 
@@ -32,6 +32,22 @@ module Delayed
32
32
 
33
33
  mattr_accessor(*SETTINGS_WITH_ARGS)
34
34
 
35
+ PARENT_PROCESS_DEFAULTS = {
36
+ server_receive_timeout: 10.0,
37
+ server_socket_timeout: 10.0,
38
+ pending_jobs_idle_timeout: 30.0,
39
+
40
+ client_connect_timeout: 2.0,
41
+ client_receive_timeout: 10.0,
42
+
43
+ # We'll accept a partial, relative path and assume we want it inside
44
+ # Rails.root with inst-jobs.sock appended if provided a directory.
45
+ server_address: 'tmp',
46
+ }.with_indifferent_access.freeze
47
+
48
+ mattr_reader(:parent_process)
49
+ @@parent_process = PARENT_PROCESS_DEFAULTS.dup
50
+
35
51
  def self.queue=(queue_name)
36
52
  raise(ArgumentError, "queue_name must not be blank") if queue_name.blank?
37
53
  @@queue = queue_name
@@ -44,7 +60,6 @@ module Delayed
44
60
  self.fetch_batch_size = 5
45
61
  self.select_random_from_batch = false
46
62
  self.silence_periodic_log = false
47
- self.parent_process_client_timeout = 10.0
48
63
 
49
64
  self.num_strands = ->(strand_name){ nil }
50
65
  self.default_job_options = ->{ Hash.new }
@@ -71,6 +86,8 @@ module Delayed
71
86
  SETTINGS.each do |setting|
72
87
  self.send("#{setting}=", config[setting.to_s]) if config.key?(setting.to_s)
73
88
  end
89
+ parent_process.client_timeout = config['parent_process_client_timeout'] if config.key?('parent_process_client_timeout')
90
+ parent_process = config['parent_process'] if config.key?('parent_process')
74
91
  end
75
92
 
76
93
  def self.default_worker_config_name
@@ -86,5 +103,14 @@ module Delayed
86
103
  end
87
104
  File.expand_path("../#{path}", root)
88
105
  end
106
+
107
+ def self.parent_process_client_timeout=(val)
108
+ parent_process['server_socket_timeout'] = Integer(val)
109
+ end
110
+
111
+ def self.parent_process=(new_config)
112
+ raise 'Parent process configurations must be a hash!' unless Hash === new_config
113
+ @@parent_process = PARENT_PROCESS_DEFAULTS.merge(new_config)
114
+ end
89
115
  end
90
116
  end
@@ -1,3 +1,3 @@
1
1
  module Delayed
2
- VERSION = "0.12.3"
2
+ VERSION = "0.13.0"
3
3
  end
@@ -1,7 +1,10 @@
1
+ require 'pathname'
1
2
  require 'socket'
2
- require 'tempfile'
3
3
  require 'timeout'
4
4
 
5
+ require_relative 'parent_process/client'
6
+ require_relative 'parent_process/server'
7
+
5
8
  module Delayed
6
9
  module WorkQueue
7
10
  # ParentProcess is a WorkQueue implementation that spawns a separate worker
@@ -24,200 +27,41 @@ class ParentProcess
24
27
  class ProtocolError < RuntimeError
25
28
  end
26
29
 
27
- def initialize
28
- @path = self.class.generate_socket_path
29
- end
30
+ attr_reader :server_address
31
+
32
+ DEFAULT_SOCKET_NAME = 'inst-jobs.sock'.freeze
33
+ private_constant :DEFAULT_SOCKET_NAME
30
34
 
31
- def self.generate_socket_path
32
- # We utilize Tempfile as a convenient way to get a socket filename in the
33
- # writeable temp directory. However, since we destroy the normal file and
34
- # write a unix socket file to the same location, we lose the hard uniqueness
35
- # guarantees of Tempfile. This is OK for this use case, we only generate one
36
- # Tempfile with this prefix.
37
- tmp = Tempfile.new("inst-jobs-#{Process.pid}-")
38
- path = tmp.path
39
- tmp.close!
40
- path
35
+ def initialize(config = Settings.parent_process)
36
+ @config = config
37
+ @server_address = generate_socket_path(config['server_address'])
41
38
  end
42
39
 
43
40
  def server(parent_pid: nil)
44
41
  # The unix_server_socket method takes care of cleaning up any existing
45
42
  # socket for us if the work queue process dies and is restarted.
46
- listen_socket = Socket.unix_server_socket(@path)
47
- Server.new(listen_socket, parent_pid: parent_pid)
43
+ listen_socket = Socket.unix_server_socket(@server_address)
44
+ Server.new(listen_socket, parent_pid: parent_pid, config: @config)
48
45
  end
49
46
 
50
47
  def client
51
- Client.new(Addrinfo.unix(@path))
52
- end
53
-
54
- module SayUtil
55
- def say(msg, level = :debug)
56
- if defined?(Rails.logger) && Rails.logger
57
- message = -> { "[#{Process.pid}]Q #{msg}" }
58
- Rails.logger.send(level, self.class.name, &message)
59
- else
60
- puts(msg)
61
- end
62
- end
48
+ Client.new(Addrinfo.unix(@server_address), config: @config)
63
49
  end
64
50
 
65
- class Client
66
- attr_reader :addrinfo
51
+ private
67
52
 
68
- include SayUtil
53
+ def generate_socket_path(supplied_path)
54
+ pathname = Pathname.new(supplied_path)
69
55
 
70
- def initialize(addrinfo)
71
- @addrinfo = addrinfo
72
- end
73
-
74
- def get_and_lock_next_available(worker_name, worker_config)
75
- @socket ||= @addrinfo.connect
76
- say("Requesting work using #{@socket.inspect}")
77
- Marshal.dump([worker_name, worker_config], @socket)
78
- response = Marshal.load(@socket)
79
- unless response.nil? || (response.is_a?(Delayed::Job) && response.locked_by == worker_name)
80
- say("Received invalid response from server: #{response.inspect}")
81
- raise(ProtocolError, "response is not a locked job: #{response.inspect}")
82
- end
83
- say("Received work from server: #{response.inspect}")
84
- response
85
- rescue SystemCallError, IOError => ex
86
- say("Work queue connection lost, reestablishing on next poll. (#{ex})", :error)
87
- # The work queue process died. Return nil to signal the worker
88
- # process should sleep as if no job was found, and then retry.
89
- @socket = nil
90
- nil
56
+ if pathname.absolute? && pathname.directory?
57
+ pathname.join(DEFAULT_SOCKET_NAME).to_s
58
+ elsif pathname.absolute?
59
+ supplied_path
60
+ else
61
+ generate_socket_path(Settings.expand_rails_path(supplied_path))
91
62
  end
92
63
  end
93
-
94
- class Server
95
- attr_reader :listen_socket
96
-
97
- include SayUtil
98
-
99
- def initialize(listen_socket, parent_pid: nil)
100
- @listen_socket = listen_socket
101
- @parent_pid = parent_pid
102
- @clients = {}
103
- @waiting_clients = {}
104
- end
105
-
106
- def connected_clients
107
- @clients.size
108
- end
109
-
110
- def all_workers_idle?
111
- !@clients.any? { |_, c| c.working }
112
- end
113
-
114
- # run the server queue worker
115
- # this method does not return, only exits or raises an exception
116
- def run
117
- say "Starting work queue process"
118
-
119
- while !exit?
120
- run_once
121
- end
122
-
123
- rescue => e
124
- say "WorkQueue Server died: #{e.inspect}", :error
125
- raise
126
- end
127
-
128
- def run_once
129
- handles = @clients.keys + [@listen_socket]
130
- timeout = Settings.sleep_delay + (rand * Settings.sleep_delay_stagger)
131
- readable, _, _ = IO.select(handles, nil, nil, timeout)
132
- if readable
133
- readable.each { |s| handle_read(s) }
134
- end
135
- check_for_work
136
- end
137
-
138
- def handle_read(socket)
139
- if socket == @listen_socket
140
- handle_accept
141
- else
142
- handle_request(socket)
143
- end
144
- end
145
-
146
- # Any error on the listen socket other than WaitReadable will bubble up
147
- # and terminate the work queue process, to be restarted by the parent daemon.
148
- def handle_accept
149
- socket, _addr = @listen_socket.accept_nonblock
150
- if socket
151
- @clients[socket] = ClientState.new(false, socket)
152
- end
153
- rescue IO::WaitReadable
154
- say("Server attempted to read listen_socket but failed with IO::WaitReadable", :error)
155
- # ignore and just try accepting again next time through the loop
156
- end
157
-
158
- def handle_request(socket)
159
- # There is an assumption here that the client will never send a partial
160
- # request and then leave the socket open. Doing so would leave us hanging
161
- # here forever. This is only a reasonable assumption because we control
162
- # the client.
163
- worker_name, worker_config = client_timeout { Marshal.load(socket) }
164
- client = @clients[socket]
165
- client.name = worker_name
166
- client.working = false
167
- (@waiting_clients[worker_config] ||= []) << client
168
-
169
- rescue SystemCallError, IOError, Timeout::Error => ex
170
- say("Receiving message from client (#{socket}) failed: #{ex.inspect}", :error)
171
- drop_socket(socket)
172
- end
173
-
174
- def check_for_work
175
- @waiting_clients.each do |(worker_config, workers)|
176
- next if workers.empty?
177
-
178
- Delayed::Worker.lifecycle.run_callbacks(:work_queue_pop, self, worker_config) do
179
- response = Delayed::Job.get_and_lock_next_available(
180
- workers.map(&:name),
181
- worker_config[:queue],
182
- worker_config[:min_priority],
183
- worker_config[:max_priority])
184
- response.each do |(worker_name, job)|
185
- client = workers.find { |worker| worker.name == worker_name }
186
- client.working = true
187
- @waiting_clients[worker_config].delete(client)
188
- begin
189
- client_timeout { Marshal.dump(job, client.socket) }
190
- rescue SystemCallError, IOError, Timeout::Error
191
- drop_socket(client.socket)
192
- end
193
- end
194
- end
195
- end
196
- end
197
-
198
- def drop_socket(socket)
199
- # this socket went away
200
- begin
201
- socket.close
202
- rescue IOError
203
- end
204
- @clients.delete(socket)
205
- end
206
-
207
- def exit?
208
- parent_exited?
209
- end
210
-
211
- def parent_exited?
212
- @parent_pid && @parent_pid != Process.ppid
213
- end
214
-
215
- def client_timeout
216
- Timeout.timeout(Settings.parent_process_client_timeout) { yield }
217
- end
218
-
219
- ClientState = Struct.new(:working, :socket, :name)
220
- end
221
64
  end
222
65
  end
223
66
  end
67
+