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.
- checksums.yaml +5 -5
- data/.gitignore +2 -0
- data/CHANGELOG.md +108 -14
- data/LICENSE.txt +1 -1
- data/README.md +49 -45
- data/bin/command_line_interface.rb +239 -0
- data/bin/que +8 -82
- data/docs/README.md +2 -0
- data/docs/active_job.md +6 -0
- data/docs/advanced_setup.md +7 -64
- data/docs/command_line_interface.md +45 -0
- data/docs/error_handling.md +65 -18
- data/docs/inspecting_the_queue.md +30 -80
- data/docs/job_helper_methods.md +27 -0
- data/docs/logging.md +3 -22
- data/docs/managing_workers.md +6 -61
- data/docs/middleware.md +15 -0
- data/docs/migrating.md +4 -7
- data/docs/multiple_queues.md +8 -4
- data/docs/shutting_down_safely.md +1 -1
- data/docs/using_plain_connections.md +39 -15
- data/docs/using_sequel.md +5 -3
- data/docs/writing_reliable_jobs.md +15 -24
- data/lib/que.rb +98 -182
- data/lib/que/active_job/extensions.rb +97 -0
- data/lib/que/active_record/connection.rb +51 -0
- data/lib/que/active_record/model.rb +48 -0
- data/lib/que/connection.rb +179 -0
- data/lib/que/connection_pool.rb +78 -0
- data/lib/que/job.rb +107 -156
- data/lib/que/job_cache.rb +240 -0
- data/lib/que/job_methods.rb +168 -0
- data/lib/que/listener.rb +176 -0
- data/lib/que/locker.rb +466 -0
- data/lib/que/metajob.rb +47 -0
- data/lib/que/migrations.rb +24 -17
- data/lib/que/migrations/4/down.sql +48 -0
- data/lib/que/migrations/4/up.sql +265 -0
- data/lib/que/poller.rb +267 -0
- data/lib/que/rails/railtie.rb +14 -0
- data/lib/que/result_queue.rb +35 -0
- data/lib/que/sequel/model.rb +51 -0
- data/lib/que/utils/assertions.rb +62 -0
- data/lib/que/utils/constantization.rb +19 -0
- data/lib/que/utils/error_notification.rb +68 -0
- data/lib/que/utils/freeze.rb +20 -0
- data/lib/que/utils/introspection.rb +50 -0
- data/lib/que/utils/json_serialization.rb +21 -0
- data/lib/que/utils/logging.rb +78 -0
- data/lib/que/utils/middleware.rb +33 -0
- data/lib/que/utils/queue_management.rb +18 -0
- data/lib/que/utils/transactions.rb +34 -0
- data/lib/que/version.rb +1 -1
- data/lib/que/worker.rb +128 -167
- data/que.gemspec +13 -2
- metadata +37 -80
- data/.rspec +0 -2
- data/.travis.yml +0 -64
- data/Gemfile +0 -24
- data/docs/customizing_que.md +0 -200
- data/lib/generators/que/install_generator.rb +0 -24
- data/lib/generators/que/templates/add_que.rb +0 -13
- data/lib/que/adapters/active_record.rb +0 -40
- data/lib/que/adapters/base.rb +0 -133
- data/lib/que/adapters/connection_pool.rb +0 -16
- data/lib/que/adapters/pg.rb +0 -21
- data/lib/que/adapters/pond.rb +0 -16
- data/lib/que/adapters/sequel.rb +0 -20
- data/lib/que/railtie.rb +0 -16
- data/lib/que/rake_tasks.rb +0 -59
- data/lib/que/sql.rb +0 -170
- data/spec/adapters/active_record_spec.rb +0 -175
- data/spec/adapters/connection_pool_spec.rb +0 -22
- data/spec/adapters/pg_spec.rb +0 -41
- data/spec/adapters/pond_spec.rb +0 -22
- data/spec/adapters/sequel_spec.rb +0 -57
- data/spec/gemfiles/Gemfile.current +0 -19
- data/spec/gemfiles/Gemfile.old +0 -19
- data/spec/gemfiles/Gemfile.older +0 -19
- data/spec/gemfiles/Gemfile.oldest +0 -19
- data/spec/spec_helper.rb +0 -129
- data/spec/support/helpers.rb +0 -25
- data/spec/support/jobs.rb +0 -35
- data/spec/support/shared_examples/adapter.rb +0 -42
- data/spec/support/shared_examples/multi_threaded_adapter.rb +0 -46
- data/spec/unit/configuration_spec.rb +0 -31
- data/spec/unit/connection_spec.rb +0 -14
- data/spec/unit/customization_spec.rb +0 -251
- data/spec/unit/enqueue_spec.rb +0 -245
- data/spec/unit/helper_spec.rb +0 -12
- data/spec/unit/logging_spec.rb +0 -101
- data/spec/unit/migrations_spec.rb +0 -84
- data/spec/unit/pool_spec.rb +0 -365
- data/spec/unit/run_spec.rb +0 -14
- data/spec/unit/states_spec.rb +0 -50
- data/spec/unit/stats_spec.rb +0 -46
- data/spec/unit/transaction_spec.rb +0 -36
- data/spec/unit/work_spec.rb +0 -596
- data/spec/unit/worker_spec.rb +0 -167
- data/tasks/benchmark.rb +0 -3
- data/tasks/rspec.rb +0 -14
- 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
|
data/lib/que/listener.rb
ADDED
@@ -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
|
data/lib/que/locker.rb
ADDED
@@ -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
|