que 0.14.3 → 1.0.0.beta

Sign up to get free protection for your applications and to get access to all the features.
Files changed (102) hide show
  1. checksums.yaml +5 -5
  2. data/.gitignore +2 -0
  3. data/CHANGELOG.md +108 -14
  4. data/LICENSE.txt +1 -1
  5. data/README.md +49 -45
  6. data/bin/command_line_interface.rb +239 -0
  7. data/bin/que +8 -82
  8. data/docs/README.md +2 -0
  9. data/docs/active_job.md +6 -0
  10. data/docs/advanced_setup.md +7 -64
  11. data/docs/command_line_interface.md +45 -0
  12. data/docs/error_handling.md +65 -18
  13. data/docs/inspecting_the_queue.md +30 -80
  14. data/docs/job_helper_methods.md +27 -0
  15. data/docs/logging.md +3 -22
  16. data/docs/managing_workers.md +6 -61
  17. data/docs/middleware.md +15 -0
  18. data/docs/migrating.md +4 -7
  19. data/docs/multiple_queues.md +8 -4
  20. data/docs/shutting_down_safely.md +1 -1
  21. data/docs/using_plain_connections.md +39 -15
  22. data/docs/using_sequel.md +5 -3
  23. data/docs/writing_reliable_jobs.md +15 -24
  24. data/lib/que.rb +98 -182
  25. data/lib/que/active_job/extensions.rb +97 -0
  26. data/lib/que/active_record/connection.rb +51 -0
  27. data/lib/que/active_record/model.rb +48 -0
  28. data/lib/que/connection.rb +179 -0
  29. data/lib/que/connection_pool.rb +78 -0
  30. data/lib/que/job.rb +107 -156
  31. data/lib/que/job_cache.rb +240 -0
  32. data/lib/que/job_methods.rb +168 -0
  33. data/lib/que/listener.rb +176 -0
  34. data/lib/que/locker.rb +466 -0
  35. data/lib/que/metajob.rb +47 -0
  36. data/lib/que/migrations.rb +24 -17
  37. data/lib/que/migrations/4/down.sql +48 -0
  38. data/lib/que/migrations/4/up.sql +265 -0
  39. data/lib/que/poller.rb +267 -0
  40. data/lib/que/rails/railtie.rb +14 -0
  41. data/lib/que/result_queue.rb +35 -0
  42. data/lib/que/sequel/model.rb +51 -0
  43. data/lib/que/utils/assertions.rb +62 -0
  44. data/lib/que/utils/constantization.rb +19 -0
  45. data/lib/que/utils/error_notification.rb +68 -0
  46. data/lib/que/utils/freeze.rb +20 -0
  47. data/lib/que/utils/introspection.rb +50 -0
  48. data/lib/que/utils/json_serialization.rb +21 -0
  49. data/lib/que/utils/logging.rb +78 -0
  50. data/lib/que/utils/middleware.rb +33 -0
  51. data/lib/que/utils/queue_management.rb +18 -0
  52. data/lib/que/utils/transactions.rb +34 -0
  53. data/lib/que/version.rb +1 -1
  54. data/lib/que/worker.rb +128 -167
  55. data/que.gemspec +13 -2
  56. metadata +37 -80
  57. data/.rspec +0 -2
  58. data/.travis.yml +0 -64
  59. data/Gemfile +0 -24
  60. data/docs/customizing_que.md +0 -200
  61. data/lib/generators/que/install_generator.rb +0 -24
  62. data/lib/generators/que/templates/add_que.rb +0 -13
  63. data/lib/que/adapters/active_record.rb +0 -40
  64. data/lib/que/adapters/base.rb +0 -133
  65. data/lib/que/adapters/connection_pool.rb +0 -16
  66. data/lib/que/adapters/pg.rb +0 -21
  67. data/lib/que/adapters/pond.rb +0 -16
  68. data/lib/que/adapters/sequel.rb +0 -20
  69. data/lib/que/railtie.rb +0 -16
  70. data/lib/que/rake_tasks.rb +0 -59
  71. data/lib/que/sql.rb +0 -170
  72. data/spec/adapters/active_record_spec.rb +0 -175
  73. data/spec/adapters/connection_pool_spec.rb +0 -22
  74. data/spec/adapters/pg_spec.rb +0 -41
  75. data/spec/adapters/pond_spec.rb +0 -22
  76. data/spec/adapters/sequel_spec.rb +0 -57
  77. data/spec/gemfiles/Gemfile.current +0 -19
  78. data/spec/gemfiles/Gemfile.old +0 -19
  79. data/spec/gemfiles/Gemfile.older +0 -19
  80. data/spec/gemfiles/Gemfile.oldest +0 -19
  81. data/spec/spec_helper.rb +0 -129
  82. data/spec/support/helpers.rb +0 -25
  83. data/spec/support/jobs.rb +0 -35
  84. data/spec/support/shared_examples/adapter.rb +0 -42
  85. data/spec/support/shared_examples/multi_threaded_adapter.rb +0 -46
  86. data/spec/unit/configuration_spec.rb +0 -31
  87. data/spec/unit/connection_spec.rb +0 -14
  88. data/spec/unit/customization_spec.rb +0 -251
  89. data/spec/unit/enqueue_spec.rb +0 -245
  90. data/spec/unit/helper_spec.rb +0 -12
  91. data/spec/unit/logging_spec.rb +0 -101
  92. data/spec/unit/migrations_spec.rb +0 -84
  93. data/spec/unit/pool_spec.rb +0 -365
  94. data/spec/unit/run_spec.rb +0 -14
  95. data/spec/unit/states_spec.rb +0 -50
  96. data/spec/unit/stats_spec.rb +0 -46
  97. data/spec/unit/transaction_spec.rb +0 -36
  98. data/spec/unit/work_spec.rb +0 -596
  99. data/spec/unit/worker_spec.rb +0 -167
  100. data/tasks/benchmark.rb +0 -3
  101. data/tasks/rspec.rb +0 -14
  102. data/tasks/safe_shutdown.rb +0 -67
@@ -0,0 +1,168 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Que
4
+ SQL[:finish_job] =
5
+ %{
6
+ UPDATE public.que_jobs
7
+ SET finished_at = now()
8
+ WHERE id = $1::bigint
9
+ }
10
+
11
+ SQL[:expire_job] =
12
+ %{
13
+ UPDATE public.que_jobs
14
+ SET error_count = error_count + 1,
15
+ expired_at = now()
16
+ WHERE id = $1::bigint
17
+ }
18
+
19
+ SQL[:destroy_job] =
20
+ %{
21
+ DELETE FROM public.que_jobs
22
+ WHERE id = $1::bigint
23
+ }
24
+
25
+ SQL[:set_error] =
26
+ %{
27
+ UPDATE public.que_jobs
28
+ SET error_count = error_count + 1,
29
+ run_at = now() + $1::float * '1 second'::interval,
30
+ last_error_message = left($2::text, 500),
31
+ last_error_backtrace = left($3::text, 10000)
32
+ WHERE id = $4::bigint
33
+ }
34
+
35
+ module JobMethods
36
+ # Note that we delegate almost all methods to the result of the que_target
37
+ # method, which could be one of a few things, depending on the circumstance.
38
+
39
+ # Run the job with error handling and cleanup logic. Optionally support
40
+ # overriding the args, because it's necessary when jobs are invoked from
41
+ # ActiveJob.
42
+ def _run(args: nil, reraise_errors: false)
43
+ if args.nil? && que_target
44
+ args = que_target.que_attrs.fetch(:args)
45
+ end
46
+
47
+ run(*args)
48
+ default_resolve_action if que_target && !que_target.que_resolved
49
+ rescue => error
50
+ raise error unless que_target
51
+
52
+ que_target.que_error = error
53
+
54
+ run_error_notifier =
55
+ begin
56
+ handle_error(error)
57
+ rescue => error_2
58
+ Que.notify_error(error_2, que_target.que_attrs)
59
+ true
60
+ end
61
+
62
+ Que.notify_error(error, que_target.que_attrs) if run_error_notifier
63
+ retry_in_default_interval unless que_target.que_resolved
64
+
65
+ raise error if reraise_errors
66
+ end
67
+
68
+ private
69
+
70
+ # This method defines the object on which the various job helper methods are
71
+ # acting. When using Que in the default configuration this will just be
72
+ # self, but when using the Que adapter for ActiveJob it'll be the actual
73
+ # underlying job object. When running an ActiveJob::Base subclass that
74
+ # includes this module through a separate adapter this will be nil - hence,
75
+ # the defensive coding in every method that no-ops if que_target is falsy.
76
+ def que_target
77
+ raise NotImplementedError
78
+ end
79
+
80
+ def resolve_que_setting(*args)
81
+ return unless que_target
82
+
83
+ que_target.class.resolve_que_setting(*args)
84
+ end
85
+
86
+ def default_resolve_action
87
+ return unless que_target
88
+
89
+ destroy
90
+ end
91
+
92
+ def expire
93
+ return unless que_target
94
+
95
+ if id = que_target.que_attrs[:id]
96
+ Que.execute :expire_job, [id]
97
+ end
98
+
99
+ que_target.que_resolved = true
100
+ end
101
+
102
+ def finish
103
+ return unless que_target
104
+
105
+ if id = que_target.que_attrs[:id]
106
+ Que.execute :finish_job, [id]
107
+ end
108
+
109
+ que_target.que_resolved = true
110
+ end
111
+
112
+ def error_count
113
+ return 0 unless que_target
114
+
115
+ count = que_target.que_attrs.fetch(:error_count)
116
+ que_target.que_error ? count + 1 : count
117
+ end
118
+
119
+ # To be overridden in subclasses.
120
+ def handle_error(error)
121
+ return unless que_target
122
+
123
+ max = resolve_que_setting(:maximum_retry_count)
124
+
125
+ if max && error_count > max
126
+ expire
127
+ else
128
+ retry_in_default_interval
129
+ end
130
+ end
131
+
132
+ def retry_in_default_interval
133
+ return unless que_target
134
+
135
+ retry_in(resolve_que_setting(:retry_interval, error_count))
136
+ end
137
+
138
+ # Explicitly check for the job id in these helpers, because it won't exist
139
+ # if we're running synchronously.
140
+ def retry_in(period)
141
+ return unless que_target
142
+
143
+ if id = que_target.que_attrs[:id]
144
+ values = [period]
145
+
146
+ if e = que_target.que_error
147
+ values << "#{e.class}: #{e.message}".slice(0, 500) << e.backtrace.join("\n").slice(0, 10000)
148
+ else
149
+ values << nil << nil
150
+ end
151
+
152
+ Que.execute :set_error, values << id
153
+ end
154
+
155
+ que_target.que_resolved = true
156
+ end
157
+
158
+ def destroy
159
+ return unless que_target
160
+
161
+ if id = que_target.que_attrs[:id]
162
+ Que.execute :destroy_job, [id]
163
+ end
164
+
165
+ que_target.que_resolved = true
166
+ end
167
+ end
168
+ end
@@ -0,0 +1,176 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Que
4
+ class Listener
5
+ MESSAGE_FORMATS = {}
6
+
7
+ attr_reader :connection, :channel
8
+
9
+ def initialize(connection:, channel: nil)
10
+ @connection = connection
11
+ @channel = channel || "que_listener_#{connection.backend_pid}"
12
+
13
+ Que.internal_log :listener_instantiate, self do
14
+ {
15
+ backend_pid: connection.backend_pid,
16
+ }
17
+ end
18
+ end
19
+
20
+ def listen
21
+ connection.execute "LISTEN #{channel}"
22
+ end
23
+
24
+ def wait_for_grouped_messages(timeout)
25
+ messages = wait_for_messages(timeout)
26
+
27
+ output = {}
28
+
29
+ messages.each do |message|
30
+ message_type = message.delete(:message_type)
31
+
32
+ (output[message_type.to_sym] ||= []) << message.freeze
33
+ end
34
+
35
+ output
36
+ end
37
+
38
+ def wait_for_messages(timeout)
39
+ # Make sure we never pass nil to this method, so we don't hang the thread.
40
+ Que.assert(Numeric, timeout)
41
+
42
+ Que.internal_log :listener_waiting, self do
43
+ {
44
+ backend_pid: connection.backend_pid,
45
+ channel: channel,
46
+ timeout: timeout,
47
+ }
48
+ end
49
+
50
+ accumulated_messages = []
51
+
52
+ # Notifications often come in batches (especially when a transaction that
53
+ # inserted many jobs commits), so we want to loop and pick up all the
54
+ # received notifications before continuing.
55
+ loop do
56
+ notification_received =
57
+ connection.wait_for_notify(timeout) do |channel, pid, payload|
58
+ # We've received a notification, so zero out the timeout before we
59
+ # loop again to check for another message. This ensures that we
60
+ # don't wait an additional `timeout` seconds after processing the
61
+ # final message before this method returns.
62
+ timeout = 0
63
+
64
+ Que.internal_log(:listener_received_notification, self) do
65
+ {
66
+ channel: channel,
67
+ backend_pid: connection.backend_pid,
68
+ source_pid: pid,
69
+ payload: payload,
70
+ }
71
+ end
72
+
73
+ # Be very defensive about the message we receive - it may not be
74
+ # valid JSON or have the structure we expect.
75
+ next unless message = parse_payload(payload)
76
+
77
+ case message
78
+ when Array then accumulated_messages.concat(message)
79
+ when Hash then accumulated_messages << message
80
+ else raise Error, "Unexpected parse_payload output: #{message.class}"
81
+ end
82
+ end
83
+
84
+ break unless notification_received
85
+ end
86
+
87
+ return accumulated_messages if accumulated_messages.empty?
88
+
89
+ Que.internal_log(:listener_received_messages, self) do
90
+ {
91
+ backend_pid: connection.backend_pid,
92
+ channel: channel,
93
+ messages: accumulated_messages,
94
+ }
95
+ end
96
+
97
+ accumulated_messages.keep_if do |message|
98
+ next unless message.is_a?(Hash)
99
+ next unless type = message[:message_type]
100
+ next unless type.is_a?(String)
101
+ next unless format = MESSAGE_FORMATS[type.to_sym]
102
+
103
+ if message_matches_format?(message, format)
104
+ true
105
+ else
106
+ error_message = [
107
+ "Message of type '#{type}' doesn't match format!",
108
+ # Massage message and format a bit to make these errors more readable.
109
+ "Message: #{Hash[message.reject{|k,v| k == :message_type}.sort_by{|k,v| k}].inspect}",
110
+ "Format: #{Hash[format.sort_by{|k,v| k}].inspect}",
111
+ ].join("\n")
112
+
113
+ Que.notify_error_async(Error.new(error_message))
114
+ false
115
+ end
116
+ end
117
+
118
+ Que.internal_log(:listener_filtered_messages, self) do
119
+ {
120
+ backend_pid: connection.backend_pid,
121
+ channel: channel,
122
+ messages: accumulated_messages,
123
+ }
124
+ end
125
+
126
+ accumulated_messages
127
+ end
128
+
129
+ def unlisten
130
+ # Be sure to drain all notifications so that any code that uses this
131
+ # connection later doesn't receive any nasty surprises.
132
+ connection.execute "UNLISTEN *"
133
+ connection.drain_notifications
134
+
135
+ Que.internal_log :listener_unlisten, self do
136
+ {
137
+ backend_pid: connection.backend_pid,
138
+ channel: channel,
139
+ }
140
+ end
141
+ end
142
+
143
+ private
144
+
145
+ def parse_payload(payload)
146
+ Que.deserialize_json(payload)
147
+ rescue JSON::ParserError => e
148
+ Que.notify_error_async(e)
149
+ nil
150
+ end
151
+
152
+ def message_matches_format?(message, format)
153
+ message_has_all_keys?(message, format) &&
154
+ message_has_no_excess_keys?(message, format) &&
155
+ message_keys_all_valid?(message, format)
156
+ end
157
+
158
+ def message_has_all_keys?(message, format)
159
+ format.all? { |k,v| message.has_key?(k) }
160
+ end
161
+
162
+ def message_has_no_excess_keys?(message, format)
163
+ message.all? { |k,v| format.has_key?(k) || k == :message_type }
164
+ end
165
+
166
+ def message_keys_all_valid?(message, format)
167
+ message.all? do |key, value|
168
+ if type = format[key]
169
+ Que.assert?(type, value)
170
+ else
171
+ true
172
+ end
173
+ end
174
+ end
175
+ end
176
+ end
@@ -0,0 +1,466 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The Locker class encapsulates a thread that is listening/polling for new
4
+ # jobs in the DB, locking them, passing their primary keys to workers, then
5
+ # cleaning up by unlocking them once the workers are done.
6
+
7
+ require 'set'
8
+
9
+ module Que
10
+ Listener::MESSAGE_FORMATS[:job_available] =
11
+ {
12
+ queue: String,
13
+ id: Integer,
14
+ run_at: TIME_REGEX,
15
+ priority: Integer,
16
+ }
17
+
18
+ SQL[:clean_lockers] =
19
+ %{
20
+ DELETE FROM public.que_lockers
21
+ WHERE pid = pg_backend_pid()
22
+ OR pid NOT IN (SELECT pid FROM pg_stat_activity)
23
+ }
24
+
25
+ SQL[:register_locker] =
26
+ %{
27
+ INSERT INTO public.que_lockers
28
+ (
29
+ pid,
30
+ worker_count,
31
+ worker_priorities,
32
+ ruby_pid,
33
+ ruby_hostname,
34
+ listening,
35
+ queues
36
+ )
37
+ VALUES
38
+ (
39
+ pg_backend_pid(),
40
+ $1::integer,
41
+ $2::integer[],
42
+ $3::integer,
43
+ $4::text,
44
+ $5::boolean,
45
+ $6::text[]
46
+ )
47
+ }
48
+
49
+ class Locker
50
+ attr_reader :thread, :workers, :job_cache, :locks
51
+
52
+ MESSAGE_RESOLVERS = {}
53
+ RESULT_RESOLVERS = {}
54
+
55
+ MESSAGE_RESOLVERS[:job_available] =
56
+ -> (messages) {
57
+ metajobs = messages.map { |key| Metajob.new(key) }
58
+ push_jobs(lock_jobs(job_cache.accept?(metajobs)))
59
+ }
60
+
61
+ RESULT_RESOLVERS[:job_finished] =
62
+ -> (messages) { finish_jobs(messages.map{|m| m.fetch(:metajob)}) }
63
+
64
+ DEFAULT_POLL_INTERVAL = 5.0
65
+ DEFAULT_WAIT_PERIOD = 50
66
+ DEFAULT_MINIMUM_QUEUE_SIZE = 2
67
+ DEFAULT_MAXIMUM_QUEUE_SIZE = 8
68
+ DEFAULT_WORKER_COUNT = 6
69
+ DEFAULT_WORKER_PRIORITIES = [10, 30, 50].freeze
70
+
71
+ def initialize(
72
+ queues: [Que.default_queue],
73
+ connection: nil,
74
+ listen: true,
75
+ poll: true,
76
+ poll_interval: DEFAULT_POLL_INTERVAL,
77
+ wait_period: DEFAULT_WAIT_PERIOD,
78
+ maximum_queue_size: DEFAULT_MAXIMUM_QUEUE_SIZE,
79
+ minimum_queue_size: DEFAULT_MINIMUM_QUEUE_SIZE,
80
+ worker_count: DEFAULT_WORKER_COUNT,
81
+ worker_priorities: DEFAULT_WORKER_PRIORITIES,
82
+ on_worker_start: nil
83
+ )
84
+
85
+ # Sanity-check all our arguments, since some users may instantiate Locker
86
+ # directly.
87
+ Que.assert [TrueClass, FalseClass], listen
88
+ Que.assert [TrueClass, FalseClass], poll
89
+
90
+ Que.assert Numeric, poll_interval
91
+ Que.assert Numeric, wait_period
92
+ Que.assert Integer, worker_count
93
+
94
+ Que.assert Array, worker_priorities
95
+ worker_priorities.each { |p| Que.assert(Integer, p) }
96
+
97
+ all_worker_priorities = worker_priorities.values_at(0...worker_count)
98
+
99
+ # We use a JobCache to track jobs and pass them to workers, and a
100
+ # ResultQueue to receive messages from workers.
101
+ @job_cache = JobCache.new(
102
+ maximum_size: maximum_queue_size,
103
+ minimum_size: minimum_queue_size,
104
+ priorities: all_worker_priorities.uniq,
105
+ )
106
+
107
+ @result_queue = ResultQueue.new
108
+
109
+ Que.internal_log :locker_instantiate, self do
110
+ {
111
+ queues: queues,
112
+ listen: listen,
113
+ poll: poll,
114
+ poll_interval: poll_interval,
115
+ wait_period: wait_period,
116
+ maximum_queue_size: maximum_queue_size,
117
+ minimum_queue_size: minimum_queue_size,
118
+ worker_count: worker_count,
119
+ worker_priorities: worker_priorities,
120
+ }
121
+ end
122
+
123
+ # Local cache of which advisory locks are held by this connection.
124
+ @locks = Set.new
125
+
126
+ @queue_names = queues.is_a?(Hash) ? queues.keys : queues
127
+ @wait_period = wait_period.to_f / 1000 # Milliseconds to seconds.
128
+
129
+ # If the worker_count exceeds the array of priorities it'll result in
130
+ # extra workers that will work jobs of any priority. For example, the
131
+ # default worker_count of 6 and the default worker priorities of [10, 30,
132
+ # 50] will result in three workers that only work jobs that meet those
133
+ # priorities, and three workers that will work any job.
134
+ @workers =
135
+ all_worker_priorities.map do |priority|
136
+ Worker.new(
137
+ priority: priority,
138
+ job_cache: @job_cache,
139
+ result_queue: @result_queue,
140
+ start_callback: on_worker_start,
141
+ )
142
+ end
143
+
144
+ # To prevent race conditions, let every worker get into a ready state
145
+ # before starting up the locker thread.
146
+ loop do
147
+ break if job_cache.waiting_count == workers.count
148
+ sleep 0.001
149
+ end
150
+
151
+ pool =
152
+ if connection
153
+ # Wrap the given connection in a dummy connection pool.
154
+ ConnectionPool.new { |&block| block.call(connection) }
155
+ else
156
+ Que.pool
157
+ end
158
+
159
+ @thread =
160
+ Thread.new do
161
+ # An error causing this thread to exit is a bug in Que, which we want
162
+ # to know about ASAP, so propagate the error if it happens.
163
+ Thread.current.abort_on_exception = true
164
+
165
+ # Give this thread priority, so it can promptly respond to NOTIFYs.
166
+ Thread.current.priority = 1
167
+
168
+ pool.checkout do |connection|
169
+ original_application_name =
170
+ connection.
171
+ execute("SHOW application_name").
172
+ first.
173
+ fetch(:application_name)
174
+
175
+ begin
176
+ @connection = connection
177
+
178
+ connection.execute(
179
+ "SELECT set_config('application_name', $1, false)",
180
+ ["Que Locker: #{connection.backend_pid}"]
181
+ )
182
+
183
+ Poller.setup(connection)
184
+
185
+ if listen
186
+ @listener = Listener.new(connection: connection)
187
+ end
188
+
189
+ if poll
190
+ @pollers =
191
+ queues.map do |queue, interval|
192
+ Poller.new(
193
+ connection: connection,
194
+ queue: queue,
195
+ poll_interval: interval || poll_interval,
196
+ )
197
+ end
198
+ end
199
+
200
+ work_loop
201
+ ensure
202
+ connection.execute(
203
+ "SELECT set_config('application_name', $1, false)",
204
+ [original_application_name]
205
+ )
206
+
207
+ Poller.cleanup(connection)
208
+ end
209
+ end
210
+ end
211
+ end
212
+
213
+ def stop!
214
+ stop; wait_for_stop
215
+ end
216
+
217
+ def stop
218
+ @job_cache.stop
219
+ @stop = true
220
+ end
221
+
222
+ def stopping?
223
+ @stop
224
+ end
225
+
226
+ def wait_for_stop
227
+ @thread.join
228
+ end
229
+
230
+ private
231
+
232
+ attr_reader :connection, :pollers
233
+
234
+ def work_loop
235
+ Que.log(
236
+ level: :debug,
237
+ event: :locker_start,
238
+ queues: @queue_names,
239
+ )
240
+
241
+ Que.internal_log :locker_start, self do
242
+ {
243
+ backend_pid: connection.backend_pid,
244
+ worker_priorities: workers.map(&:priority),
245
+ pollers: pollers && pollers.map { |p| [p.queue, p.poll_interval] }
246
+ }
247
+ end
248
+
249
+ begin
250
+ @listener.listen if @listener
251
+
252
+ # A previous locker that didn't exit cleanly may have left behind
253
+ # a bad locker record, so clean up before registering.
254
+ connection.execute :clean_lockers
255
+ connection.execute :register_locker, [
256
+ @workers.count,
257
+ "{#{@workers.map(&:priority).map{|p| p || 'NULL'}.join(',')}}",
258
+ Process.pid,
259
+ CURRENT_HOSTNAME,
260
+ !!@listener,
261
+ "{\"#{@queue_names.join('","')}\"}",
262
+ ]
263
+
264
+ {} while cycle
265
+
266
+ Que.log(
267
+ level: :debug,
268
+ event: :locker_stop,
269
+ )
270
+
271
+ unlock_jobs(@job_cache.clear)
272
+
273
+ @workers.each(&:wait_until_stopped)
274
+
275
+ handle_results
276
+ ensure
277
+ connection.execute :clean_lockers
278
+
279
+ @listener.unlisten if @listener
280
+ end
281
+ end
282
+
283
+ def cycle
284
+ # Poll at the start of a cycle, so that when the worker starts up we can
285
+ # load up the queue with jobs immediately.
286
+ poll
287
+
288
+ # If we got the stop call while we were polling, break before going to
289
+ # sleep.
290
+ return if @stop
291
+
292
+ # The main sleeping part of the cycle. If this is a listening locker, this
293
+ # is where we wait for notifications.
294
+ wait
295
+
296
+ # Manage any job output we got while we were sleeping.
297
+ handle_results
298
+
299
+ # If we haven't gotten the stop signal, cycle again.
300
+ !@stop
301
+ end
302
+
303
+ def poll
304
+ # Only poll when there are pollers to use (that is, when polling is
305
+ # enabled) and when the local queue has dropped below the configured
306
+ # minimum size.
307
+ return unless pollers && job_cache.jobs_needed?
308
+
309
+ pollers.each do |poller|
310
+ priorities = job_cache.available_priorities
311
+ break if priorities.empty?
312
+
313
+ Que.internal_log(:locker_polling, self) { {priorities: priorities, held_locks: @locks.to_a, queue: poller.queue} }
314
+
315
+ if metajobs = poller.poll(priorities: priorities, held_locks: @locks)
316
+ metajobs.each do |metajob|
317
+ mark_id_as_locked(metajob.id)
318
+ end
319
+
320
+ push_jobs(metajobs)
321
+ end
322
+ end
323
+ end
324
+
325
+ def wait
326
+ if @listener
327
+ @listener.wait_for_grouped_messages(@wait_period).each do |type, messages|
328
+ if resolver = MESSAGE_RESOLVERS[type]
329
+ instance_exec messages, &resolver
330
+ else
331
+ raise Error, "Unexpected message type: #{type.inspect}"
332
+ end
333
+ end
334
+ else
335
+ sleep(@wait_period)
336
+ end
337
+ end
338
+
339
+ def handle_results
340
+ messages_by_type =
341
+ @result_queue.clear.group_by{|r| r.fetch(:message_type)}
342
+
343
+ messages_by_type.each do |type, messages|
344
+ if resolver = RESULT_RESOLVERS[type]
345
+ instance_exec messages, &resolver
346
+ else
347
+ raise Error, "Unexpected result type: #{type.inspect}"
348
+ end
349
+ end
350
+ end
351
+
352
+ def lock_jobs(metajobs)
353
+ metajobs.reject! { |m| @locks.include?(m.id) }
354
+ return metajobs if metajobs.empty?
355
+
356
+ ids = metajobs.map{|m| m.id.to_i}
357
+
358
+ Que.internal_log :locker_locking, self do
359
+ {
360
+ backend_pid: connection.backend_pid,
361
+ ids: ids,
362
+ }
363
+ end
364
+
365
+ jobs =
366
+ connection.execute \
367
+ <<-SQL
368
+ WITH jobs AS (
369
+ SELECT * FROM que_jobs WHERE id IN (#{ids.join(', ')})
370
+ )
371
+ SELECT * FROM jobs WHERE pg_try_advisory_lock(id)
372
+ SQL
373
+
374
+ jobs_by_id = {}
375
+
376
+ jobs.each do |job|
377
+ id = job.fetch(:id)
378
+ mark_id_as_locked(id)
379
+ jobs_by_id[id] = job
380
+ end
381
+
382
+ metajobs.keep_if do |metajob|
383
+ if job = jobs_by_id[metajob.id]
384
+ metajob.set_job(job)
385
+ true
386
+ else
387
+ false
388
+ end
389
+ end
390
+ end
391
+
392
+ def push_jobs(metajobs)
393
+ return if metajobs.empty?
394
+
395
+ # First check that the jobs are all still visible/available in the DB.
396
+ ids = metajobs.map(&:id)
397
+
398
+ verified_ids =
399
+ connection.execute(
400
+ <<-SQL
401
+ SELECT id
402
+ FROM public.que_jobs
403
+ WHERE finished_at IS NULL
404
+ AND expired_at IS NULL
405
+ AND id IN (#{ids.join(', ')})
406
+ SQL
407
+ ).map{|h| h[:id]}.to_set
408
+
409
+ good, bad = metajobs.partition{|mj| verified_ids.include?(mj.id)}
410
+
411
+ displaced = @job_cache.push(*good) || []
412
+
413
+ # Unlock any low-importance jobs the new ones may displace.
414
+ if bad.any? || displaced.any?
415
+ unlock_jobs(bad + displaced)
416
+ end
417
+ end
418
+
419
+ def finish_jobs(metajobs)
420
+ unlock_jobs(metajobs)
421
+ end
422
+
423
+ def unlock_jobs(metajobs)
424
+ return if metajobs.empty?
425
+
426
+ # Unclear how untrusted input would get passed to this method, but since
427
+ # we need string interpolation here, make sure we only have integers.
428
+ ids = metajobs.map { |job| job.id.to_i }
429
+
430
+ Que.internal_log :locker_unlocking, self do
431
+ {
432
+ backend_pid: connection.backend_pid,
433
+ ids: ids,
434
+ }
435
+ end
436
+
437
+ values = ids.join('), (')
438
+
439
+ results =
440
+ connection.execute \
441
+ "SELECT pg_advisory_unlock(v.i) FROM (VALUES (#{values})) v (i)"
442
+
443
+ results.each do |result|
444
+ Que.assert(result.fetch(:pg_advisory_unlock)) do
445
+ [
446
+ "Tried to unlock a job we hadn't locked!",
447
+ results.inspect,
448
+ ids.inspect,
449
+ ].join(' ')
450
+ end
451
+ end
452
+
453
+ ids.each do |id|
454
+ Que.assert(@locks.delete?(id)) do
455
+ "Tried to remove a local lock that didn't exist!: #{id}"
456
+ end
457
+ end
458
+ end
459
+
460
+ def mark_id_as_locked(id)
461
+ Que.assert(@locks.add?(id)) do
462
+ "Tried to lock a job that was already locked: #{id}"
463
+ end
464
+ end
465
+ end
466
+ end