que 0.14.3 → 1.0.0.beta
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 +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
|