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
data/lib/que/metajob.rb
ADDED
@@ -0,0 +1,47 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# A thin wrapper around a job's data that lets us do things like sort easily and
|
4
|
+
# make sure that run_at is in the format we want.
|
5
|
+
|
6
|
+
module Que
|
7
|
+
class Metajob
|
8
|
+
SORT_KEYS = [:priority, :run_at, :id].freeze
|
9
|
+
|
10
|
+
attr_reader :job
|
11
|
+
|
12
|
+
def initialize(job)
|
13
|
+
set_job(job)
|
14
|
+
end
|
15
|
+
|
16
|
+
def set_job(job)
|
17
|
+
if (run_at = job.fetch(:run_at)).is_a?(Time)
|
18
|
+
job[:run_at] = run_at.utc.iso8601(6)
|
19
|
+
end
|
20
|
+
|
21
|
+
@job = job
|
22
|
+
end
|
23
|
+
|
24
|
+
def id
|
25
|
+
job.fetch(:id)
|
26
|
+
end
|
27
|
+
|
28
|
+
def <=>(other)
|
29
|
+
k1 = job
|
30
|
+
k2 = other.job
|
31
|
+
|
32
|
+
SORT_KEYS.each do |key|
|
33
|
+
value1 = k1.fetch(key)
|
34
|
+
value2 = k2.fetch(key)
|
35
|
+
|
36
|
+
return -1 if value1 < value2
|
37
|
+
return 1 if value1 > value2
|
38
|
+
end
|
39
|
+
|
40
|
+
0
|
41
|
+
end
|
42
|
+
|
43
|
+
def priority_sufficient?(threshold)
|
44
|
+
threshold.nil? || job.fetch(:priority) <= threshold
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
data/lib/que/migrations.rb
CHANGED
@@ -3,28 +3,33 @@
|
|
3
3
|
module Que
|
4
4
|
module Migrations
|
5
5
|
# In order to ship a schema change, add the relevant up and down sql files
|
6
|
-
# to the migrations directory, and bump the version
|
7
|
-
|
8
|
-
CURRENT_VERSION = 3
|
6
|
+
# to the migrations directory, and bump the version here.
|
7
|
+
CURRENT_VERSION = 4
|
9
8
|
|
10
9
|
class << self
|
11
|
-
def migrate!(
|
10
|
+
def migrate!(version:)
|
12
11
|
Que.transaction do
|
13
|
-
|
12
|
+
current = db_version
|
14
13
|
|
15
|
-
if
|
14
|
+
if current == version
|
16
15
|
return
|
17
16
|
elsif current < version
|
18
|
-
direction =
|
17
|
+
direction = :up
|
19
18
|
steps = ((current + 1)..version).to_a
|
20
19
|
elsif current > version
|
21
|
-
direction =
|
20
|
+
direction = :down
|
22
21
|
steps = ((version + 1)..current).to_a.reverse
|
23
22
|
end
|
24
23
|
|
25
24
|
steps.each do |step|
|
26
|
-
|
27
|
-
|
25
|
+
filename = [
|
26
|
+
File.dirname(__FILE__),
|
27
|
+
'migrations',
|
28
|
+
step,
|
29
|
+
direction,
|
30
|
+
].join('/') << '.sql'
|
31
|
+
|
32
|
+
Que.execute(File.read(filename))
|
28
33
|
end
|
29
34
|
|
30
35
|
set_db_version(version)
|
@@ -32,18 +37,20 @@ module Que
|
|
32
37
|
end
|
33
38
|
|
34
39
|
def db_version
|
35
|
-
result =
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
40
|
+
result =
|
41
|
+
Que.execute <<-SQL
|
42
|
+
SELECT relname, description
|
43
|
+
FROM pg_class
|
44
|
+
LEFT JOIN pg_description ON pg_description.objoid = pg_class.oid
|
45
|
+
WHERE relname = 'que_jobs'
|
46
|
+
SQL
|
41
47
|
|
42
48
|
if result.none?
|
43
49
|
# No table in the database at all.
|
44
50
|
0
|
45
51
|
elsif (d = result.first[:description]).nil?
|
46
|
-
# There's a table, it was just created before the migration system
|
52
|
+
# There's a table, it was just created before the migration system
|
53
|
+
# existed.
|
47
54
|
1
|
48
55
|
else
|
49
56
|
d.to_i
|
@@ -0,0 +1,48 @@
|
|
1
|
+
ALTER TABLE que_jobs RESET (fillfactor);
|
2
|
+
|
3
|
+
ALTER TABLE que_jobs DROP CONSTRAINT que_jobs_pkey;
|
4
|
+
DROP INDEX que_poll_idx;
|
5
|
+
DROP INDEX que_jobs_data_gin_idx;
|
6
|
+
|
7
|
+
DROP TRIGGER que_job_notify ON que_jobs;
|
8
|
+
DROP FUNCTION que_job_notify();
|
9
|
+
DROP TRIGGER que_state_notify ON que_jobs;
|
10
|
+
DROP FUNCTION que_state_notify();
|
11
|
+
DROP FUNCTION que_determine_job_state(que_jobs);
|
12
|
+
DROP TABLE que_lockers;
|
13
|
+
|
14
|
+
DROP TABLE que_values;
|
15
|
+
DROP INDEX que_jobs_args_gin_idx;
|
16
|
+
|
17
|
+
ALTER TABLE que_jobs RENAME COLUMN id TO job_id;
|
18
|
+
ALTER SEQUENCE que_jobs_id_seq RENAME TO que_jobs_job_id_seq;
|
19
|
+
|
20
|
+
ALTER TABLE que_jobs RENAME COLUMN last_error_message TO last_error;
|
21
|
+
|
22
|
+
DELETE FROM que_jobs WHERE (finished_at IS NOT NULL OR expired_at IS NOT NULL);
|
23
|
+
|
24
|
+
ALTER TABLE que_jobs
|
25
|
+
DROP CONSTRAINT error_length,
|
26
|
+
DROP CONSTRAINT queue_length,
|
27
|
+
DROP CONSTRAINT job_class_length,
|
28
|
+
DROP CONSTRAINT valid_args,
|
29
|
+
DROP COLUMN finished_at,
|
30
|
+
DROP COLUMN expired_at,
|
31
|
+
ALTER args TYPE JSON using args::json;
|
32
|
+
|
33
|
+
UPDATE que_jobs
|
34
|
+
SET
|
35
|
+
queue = CASE queue WHEN 'default' THEN '' ELSE queue END,
|
36
|
+
last_error = last_error || coalesce(E'\n' || last_error_backtrace, '');
|
37
|
+
|
38
|
+
ALTER TABLE que_jobs
|
39
|
+
DROP COLUMN data,
|
40
|
+
DROP COLUMN last_error_backtrace,
|
41
|
+
ALTER COLUMN args SET NOT NULL,
|
42
|
+
ALTER COLUMN args SET DEFAULT '[]',
|
43
|
+
ALTER COLUMN queue SET DEFAULT '';
|
44
|
+
|
45
|
+
ALTER TABLE que_jobs
|
46
|
+
ADD PRIMARY KEY (queue, priority, run_at, job_id);
|
47
|
+
|
48
|
+
DROP FUNCTION que_validate_tags(jsonb);
|
@@ -0,0 +1,265 @@
|
|
1
|
+
ALTER TABLE que_jobs SET (fillfactor = 90);
|
2
|
+
ALTER TABLE que_jobs RENAME COLUMN last_error TO last_error_message;
|
3
|
+
ALTER TABLE que_jobs RENAME COLUMN job_id TO id;
|
4
|
+
ALTER TABLE que_jobs RENAME COLUMN args TO old_args;
|
5
|
+
ALTER SEQUENCE que_jobs_job_id_seq RENAME TO que_jobs_id_seq;
|
6
|
+
|
7
|
+
ALTER TABLE que_jobs
|
8
|
+
ADD COLUMN last_error_backtrace text,
|
9
|
+
ADD COLUMN finished_at timestamptz,
|
10
|
+
ADD COLUMN expired_at timestamptz,
|
11
|
+
ADD COLUMN args JSONB,
|
12
|
+
ADD COLUMN data JSONB;
|
13
|
+
|
14
|
+
ALTER TABLE que_jobs DROP CONSTRAINT que_jobs_pkey;
|
15
|
+
|
16
|
+
UPDATE que_jobs
|
17
|
+
SET
|
18
|
+
queue = CASE queue WHEN '' THEN 'default' ELSE queue END,
|
19
|
+
last_error_backtrace =
|
20
|
+
CASE
|
21
|
+
WHEN last_error_message ~ '\n'
|
22
|
+
THEN left(regexp_replace(last_error_message, '^[^\n]+\n', ''), 10000)
|
23
|
+
ELSE
|
24
|
+
NULL
|
25
|
+
END,
|
26
|
+
last_error_message = left(substring(last_error_message from '^[^\n]+'), 500),
|
27
|
+
args =
|
28
|
+
CASE json_typeof(old_args)
|
29
|
+
WHEN 'array' THEN old_args::jsonb
|
30
|
+
ELSE jsonb_build_array(old_args)
|
31
|
+
END,
|
32
|
+
data = '{}'::jsonb;
|
33
|
+
|
34
|
+
CREATE FUNCTION que_validate_tags(tags_array jsonb) RETURNS boolean AS $$
|
35
|
+
SELECT bool_and(
|
36
|
+
jsonb_typeof(value) = 'string'
|
37
|
+
AND
|
38
|
+
char_length(value::text) <= 100
|
39
|
+
)
|
40
|
+
FROM jsonb_array_elements(tags_array)
|
41
|
+
$$
|
42
|
+
LANGUAGE SQL;
|
43
|
+
|
44
|
+
-- Now that we're done rewriting data, add new indexes.
|
45
|
+
CREATE INDEX que_poll_idx ON que_jobs (queue, priority, run_at, id) WHERE (finished_at IS NULL AND expired_at IS NULL);
|
46
|
+
CREATE INDEX que_jobs_data_gin_idx ON que_jobs USING gin (data jsonb_path_ops);
|
47
|
+
CREATE INDEX que_jobs_args_gin_idx ON que_jobs USING gin (args jsonb_path_ops);
|
48
|
+
|
49
|
+
ALTER TABLE que_jobs
|
50
|
+
ADD PRIMARY KEY (id),
|
51
|
+
DROP COLUMN old_args,
|
52
|
+
ALTER COLUMN queue SET DEFAULT 'default',
|
53
|
+
ALTER COLUMN args SET DEFAULT '[]',
|
54
|
+
ALTER COLUMN args SET NOT NULL,
|
55
|
+
ALTER COLUMN data SET DEFAULT '{}',
|
56
|
+
ALTER COLUMN data SET NOT NULL,
|
57
|
+
ADD CONSTRAINT queue_length CHECK (
|
58
|
+
char_length(queue) <= 100
|
59
|
+
),
|
60
|
+
ADD CONSTRAINT job_class_length CHECK (
|
61
|
+
char_length(
|
62
|
+
CASE job_class
|
63
|
+
WHEN 'ActiveJob::QueueAdapters::QueAdapter::JobWrapper' THEN
|
64
|
+
args->0->>'job_class'
|
65
|
+
ELSE
|
66
|
+
job_class
|
67
|
+
END
|
68
|
+
) <= 200
|
69
|
+
),
|
70
|
+
ADD CONSTRAINT valid_args CHECK (
|
71
|
+
(jsonb_typeof(args) = 'array')
|
72
|
+
),
|
73
|
+
ADD CONSTRAINT valid_data CHECK (
|
74
|
+
(jsonb_typeof(data) = 'object')
|
75
|
+
AND
|
76
|
+
(
|
77
|
+
(NOT data ? 'tags')
|
78
|
+
OR
|
79
|
+
(
|
80
|
+
(jsonb_typeof(data->'tags') = 'array')
|
81
|
+
AND
|
82
|
+
(jsonb_array_length(data->'tags') <= 5)
|
83
|
+
AND
|
84
|
+
(public.que_validate_tags(data->'tags'))
|
85
|
+
)
|
86
|
+
)
|
87
|
+
),
|
88
|
+
ADD CONSTRAINT error_length CHECK (
|
89
|
+
(char_length(last_error_message) <= 500) AND
|
90
|
+
(char_length(last_error_backtrace) <= 10000)
|
91
|
+
);
|
92
|
+
|
93
|
+
-- This is somewhat heretical, but we're going to need some more flexible
|
94
|
+
-- storage to support various features without requiring a ton of migrations,
|
95
|
+
-- which would be a lot of hassle for users. Hopefully this will be used smartly
|
96
|
+
-- and sparingly (famous last words).
|
97
|
+
CREATE TABLE que_values (
|
98
|
+
key text PRIMARY KEY,
|
99
|
+
value jsonb NOT NULL DEFAULT '{}',
|
100
|
+
CONSTRAINT valid_value CHECK (jsonb_typeof(value) = 'object')
|
101
|
+
)
|
102
|
+
WITH (FILLFACTOR=90);
|
103
|
+
|
104
|
+
CREATE UNLOGGED TABLE que_lockers (
|
105
|
+
pid integer NOT NULL CONSTRAINT que_lockers_pkey PRIMARY KEY,
|
106
|
+
worker_count integer NOT NULL,
|
107
|
+
worker_priorities integer[] NOT NULL,
|
108
|
+
ruby_pid integer NOT NULL,
|
109
|
+
ruby_hostname text NOT NULL,
|
110
|
+
queues text[] NOT NULL,
|
111
|
+
listening boolean NOT NULL,
|
112
|
+
|
113
|
+
CONSTRAINT valid_worker_priorities CHECK (
|
114
|
+
(array_ndims(worker_priorities) = 1)
|
115
|
+
AND
|
116
|
+
(array_length(worker_priorities, 1) IS NOT NULL) -- Doesn't do zero.
|
117
|
+
),
|
118
|
+
|
119
|
+
CONSTRAINT valid_queues CHECK (
|
120
|
+
(array_ndims(queues) = 1)
|
121
|
+
AND
|
122
|
+
(array_length(queues, 1) IS NOT NULL) -- Doesn't do zero.
|
123
|
+
)
|
124
|
+
);
|
125
|
+
|
126
|
+
CREATE FUNCTION que_job_notify() RETURNS trigger AS $$
|
127
|
+
DECLARE
|
128
|
+
locker_pid integer;
|
129
|
+
sort_key json;
|
130
|
+
BEGIN
|
131
|
+
-- Don't do anything if the job is scheduled for a future time.
|
132
|
+
IF NEW.run_at IS NOT NULL AND NEW.run_at > now() THEN
|
133
|
+
RETURN null;
|
134
|
+
END IF;
|
135
|
+
|
136
|
+
-- Pick a locker to notify of the job's insertion, weighted by their number
|
137
|
+
-- of workers. Should bounce pseudorandomly between lockers on each
|
138
|
+
-- invocation, hence the md5-ordering, but still touch each one equally,
|
139
|
+
-- hence the modulo using the job_id.
|
140
|
+
SELECT pid
|
141
|
+
INTO locker_pid
|
142
|
+
FROM (
|
143
|
+
SELECT *, last_value(row_number) OVER () + 1 AS count
|
144
|
+
FROM (
|
145
|
+
SELECT *, row_number() OVER () - 1 AS row_number
|
146
|
+
FROM (
|
147
|
+
SELECT *
|
148
|
+
FROM public.que_lockers ql, generate_series(1, ql.worker_count) AS id
|
149
|
+
WHERE listening AND queues @> ARRAY[NEW.queue]
|
150
|
+
ORDER BY md5(pid::text || id::text)
|
151
|
+
) t1
|
152
|
+
) t2
|
153
|
+
) t3
|
154
|
+
WHERE NEW.id % count = row_number;
|
155
|
+
|
156
|
+
IF locker_pid IS NOT NULL THEN
|
157
|
+
-- There's a size limit to what can be broadcast via LISTEN/NOTIFY, so
|
158
|
+
-- rather than throw errors when someone enqueues a big job, just
|
159
|
+
-- broadcast the most pertinent information, and let the locker query for
|
160
|
+
-- the record after it's taken the lock. The worker will have to hit the
|
161
|
+
-- DB in order to make sure the job is still visible anyway.
|
162
|
+
SELECT row_to_json(t)
|
163
|
+
INTO sort_key
|
164
|
+
FROM (
|
165
|
+
SELECT
|
166
|
+
'job_available' AS message_type,
|
167
|
+
NEW.queue AS queue,
|
168
|
+
NEW.priority AS priority,
|
169
|
+
NEW.id AS id,
|
170
|
+
-- Make sure we output timestamps as UTC ISO 8601
|
171
|
+
to_char(NEW.run_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"') AS run_at
|
172
|
+
) t;
|
173
|
+
|
174
|
+
PERFORM pg_notify('que_listener_' || locker_pid::text, sort_key::text);
|
175
|
+
END IF;
|
176
|
+
|
177
|
+
RETURN null;
|
178
|
+
END
|
179
|
+
$$
|
180
|
+
LANGUAGE plpgsql;
|
181
|
+
|
182
|
+
CREATE TRIGGER que_job_notify
|
183
|
+
AFTER INSERT ON que_jobs
|
184
|
+
FOR EACH ROW
|
185
|
+
EXECUTE PROCEDURE public.que_job_notify();
|
186
|
+
|
187
|
+
CREATE FUNCTION que_determine_job_state(job public.que_jobs) RETURNS text AS $$
|
188
|
+
SELECT
|
189
|
+
CASE
|
190
|
+
WHEN job.expired_at IS NOT NULL THEN 'expired'
|
191
|
+
WHEN job.finished_at IS NOT NULL THEN 'finished'
|
192
|
+
WHEN job.error_count > 0 THEN 'errored'
|
193
|
+
WHEN job.run_at > CURRENT_TIMESTAMP THEN 'scheduled'
|
194
|
+
ELSE 'ready'
|
195
|
+
END
|
196
|
+
$$
|
197
|
+
LANGUAGE SQL;
|
198
|
+
|
199
|
+
CREATE FUNCTION que_state_notify() RETURNS trigger AS $$
|
200
|
+
DECLARE
|
201
|
+
row record;
|
202
|
+
message json;
|
203
|
+
previous_state text;
|
204
|
+
current_state text;
|
205
|
+
BEGIN
|
206
|
+
IF TG_OP = 'INSERT' THEN
|
207
|
+
previous_state := 'nonexistent';
|
208
|
+
current_state := public.que_determine_job_state(NEW);
|
209
|
+
row := NEW;
|
210
|
+
ELSIF TG_OP = 'DELETE' THEN
|
211
|
+
previous_state := public.que_determine_job_state(OLD);
|
212
|
+
current_state := 'nonexistent';
|
213
|
+
row := OLD;
|
214
|
+
ELSIF TG_OP = 'UPDATE' THEN
|
215
|
+
previous_state := public.que_determine_job_state(OLD);
|
216
|
+
current_state := public.que_determine_job_state(NEW);
|
217
|
+
|
218
|
+
-- If the state didn't change, short-circuit.
|
219
|
+
IF previous_state = current_state THEN
|
220
|
+
RETURN null;
|
221
|
+
END IF;
|
222
|
+
|
223
|
+
row := NEW;
|
224
|
+
ELSE
|
225
|
+
RAISE EXCEPTION 'Unrecognized TG_OP: %', TG_OP;
|
226
|
+
END IF;
|
227
|
+
|
228
|
+
SELECT row_to_json(t)
|
229
|
+
INTO message
|
230
|
+
FROM (
|
231
|
+
SELECT
|
232
|
+
'job_change' AS message_type,
|
233
|
+
row.id AS id,
|
234
|
+
row.queue AS queue,
|
235
|
+
|
236
|
+
coalesce(row.data->'tags', '[]'::jsonb) AS tags,
|
237
|
+
|
238
|
+
to_char(row.run_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"') AS run_at,
|
239
|
+
to_char(now() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"') AS time,
|
240
|
+
|
241
|
+
CASE row.job_class
|
242
|
+
WHEN 'ActiveJob::QueueAdapters::QueAdapter::JobWrapper' THEN
|
243
|
+
coalesce(
|
244
|
+
row.args->0->>'job_class',
|
245
|
+
'ActiveJob::QueueAdapters::QueAdapter::JobWrapper'
|
246
|
+
)
|
247
|
+
ELSE
|
248
|
+
row.job_class
|
249
|
+
END AS job_class,
|
250
|
+
|
251
|
+
previous_state AS previous_state,
|
252
|
+
current_state AS current_state
|
253
|
+
) t;
|
254
|
+
|
255
|
+
PERFORM pg_notify('que_state', message::text);
|
256
|
+
|
257
|
+
RETURN null;
|
258
|
+
END
|
259
|
+
$$
|
260
|
+
LANGUAGE plpgsql;
|
261
|
+
|
262
|
+
CREATE TRIGGER que_state_notify
|
263
|
+
AFTER INSERT OR UPDATE OR DELETE ON que_jobs
|
264
|
+
FOR EACH ROW
|
265
|
+
EXECUTE PROCEDURE public.que_state_notify();
|
data/lib/que/poller.rb
ADDED
@@ -0,0 +1,267 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Que
|
4
|
+
class Poller
|
5
|
+
# The following SQL statement locks a batch of jobs using a Postgres
|
6
|
+
# recursive CTE [1].
|
7
|
+
#
|
8
|
+
# As noted by the Postgres documentation, it may be slightly easier to
|
9
|
+
# think about this expression as iteration rather than recursion, despite
|
10
|
+
# the `RECURSION` nomenclature defined by the SQL standards committee.
|
11
|
+
# Recursion is used here so that jobs in the table can be iterated one-by-
|
12
|
+
# one until a lock can be acquired, where a non-recursive `SELECT` would
|
13
|
+
# have the undesirable side-effect of locking jobs unnecessarily. For
|
14
|
+
# example, the following might lock more than five jobs during execution:
|
15
|
+
#
|
16
|
+
# SELECT (j).*, pg_try_advisory_lock((j).id) AS locked
|
17
|
+
# FROM public.que_jobs AS j
|
18
|
+
# LIMIT 5;
|
19
|
+
#
|
20
|
+
# The CTE will initially produce an "anchor" from the non-recursive term
|
21
|
+
# (i.e. before the `UNION`), and then use it as the contents of the
|
22
|
+
# working table as it continues to iterate through `que_jobs` looking for
|
23
|
+
# locks. The jobs table has an index on (priority, run_at, id) which
|
24
|
+
# allows it to walk the jobs table in a stable manner. As noted above, the
|
25
|
+
# recursion examines/locks one job at a time. Every time the recursive
|
26
|
+
# entry runs, it's output becomes the new contents of the working table,
|
27
|
+
# and what was previously in the working table is appended to the final
|
28
|
+
# result set. For more information on the basic workings of recursive
|
29
|
+
# CTEs, see http://www.postgresql.org/docs/devel/static/queries-with.html
|
30
|
+
#
|
31
|
+
# The polling query is provided a JSONB hash of desired job priorities.
|
32
|
+
# For example, if the locker has three workers free that can work a
|
33
|
+
# priority less than 5, and two workers free that can work a priority less
|
34
|
+
# than 10, the provided priority document is `{"5":3,"10":2}`. The query
|
35
|
+
# uses this information to decide what jobs to lock - if only high-
|
36
|
+
# priority workers were available, it wouldn't make sense to retrieve low-
|
37
|
+
# priority jobs.
|
38
|
+
#
|
39
|
+
# As each job is retrieved from the table, it is passed to
|
40
|
+
# lock_and_update_priorities() (which, for future flexibility, we define
|
41
|
+
# as a temporary function attached to the connection rather than embedding
|
42
|
+
# permanently into the DB schema). lock_and_update_priorities() attempts
|
43
|
+
# to lock the given job and, if it is able to, updates the priorities
|
44
|
+
# document to reflect that a job was available for that given priority.
|
45
|
+
# When the priorities document is emptied (all the counts of desired jobs
|
46
|
+
# for the various priorities have reached zero and been removed), the
|
47
|
+
# recursive query returns an empty result and the recursion breaks. This
|
48
|
+
# also happens if there aren't enough appropriate jobs in the jobs table.
|
49
|
+
#
|
50
|
+
# Also note the use of JOIN LATERAL to combine the job data with the
|
51
|
+
# output of lock_and_update_priorities(). The naive approach would be to
|
52
|
+
# write the SELECT as `SELECT (j).*, (lock_and_update_priorities(..., j)).*`,
|
53
|
+
# but the asterisk-expansion of the latter composite row causes the function
|
54
|
+
# to be evaluated twice, and to thereby take the advisory lock twice,
|
55
|
+
# which complicates the later unlocking step.
|
56
|
+
#
|
57
|
+
# Thanks to RhodiumToad in #postgresql for help with the original
|
58
|
+
# (simpler) version of the recursive job lock CTE.
|
59
|
+
|
60
|
+
SQL[:poll_jobs] =
|
61
|
+
%{
|
62
|
+
WITH RECURSIVE jobs AS (
|
63
|
+
SELECT
|
64
|
+
(j).*,
|
65
|
+
l.locked,
|
66
|
+
l.remaining_priorities
|
67
|
+
FROM (
|
68
|
+
SELECT j
|
69
|
+
FROM public.que_jobs AS j
|
70
|
+
WHERE queue = $1::text
|
71
|
+
AND NOT id = ANY($2::bigint[])
|
72
|
+
AND priority <= pg_temp.que_highest_remaining_priority($3::jsonb)
|
73
|
+
AND run_at <= now()
|
74
|
+
AND finished_at IS NULL AND expired_at IS NULL
|
75
|
+
ORDER BY priority, run_at, id
|
76
|
+
LIMIT 1
|
77
|
+
) AS t1
|
78
|
+
JOIN LATERAL (SELECT * FROM pg_temp.lock_and_update_priorities($3::jsonb, j)) AS l ON true
|
79
|
+
UNION ALL (
|
80
|
+
SELECT
|
81
|
+
(j).*,
|
82
|
+
l.locked,
|
83
|
+
l.remaining_priorities
|
84
|
+
FROM (
|
85
|
+
SELECT
|
86
|
+
remaining_priorities,
|
87
|
+
(
|
88
|
+
SELECT j
|
89
|
+
FROM public.que_jobs AS j
|
90
|
+
WHERE queue = $1::text
|
91
|
+
AND NOT id = ANY($2::bigint[])
|
92
|
+
AND priority <= pg_temp.que_highest_remaining_priority(jobs.remaining_priorities)
|
93
|
+
AND run_at <= now()
|
94
|
+
AND finished_at IS NULL AND expired_at IS NULL
|
95
|
+
AND (priority, run_at, id) >
|
96
|
+
(jobs.priority, jobs.run_at, jobs.id)
|
97
|
+
ORDER BY priority, run_at, id
|
98
|
+
LIMIT 1
|
99
|
+
) AS j
|
100
|
+
|
101
|
+
FROM jobs
|
102
|
+
WHERE jobs.id IS NOT NULL AND jobs.remaining_priorities != '{}'::jsonb
|
103
|
+
LIMIT 1
|
104
|
+
) AS t1
|
105
|
+
JOIN LATERAL (SELECT * FROM pg_temp.lock_and_update_priorities(remaining_priorities, j)) AS l ON true
|
106
|
+
)
|
107
|
+
)
|
108
|
+
SELECT *
|
109
|
+
FROM jobs
|
110
|
+
WHERE locked
|
111
|
+
}
|
112
|
+
|
113
|
+
attr_reader \
|
114
|
+
:connection,
|
115
|
+
:queue,
|
116
|
+
:poll_interval,
|
117
|
+
:last_polled_at,
|
118
|
+
:last_poll_satisfied
|
119
|
+
|
120
|
+
def initialize(
|
121
|
+
connection:,
|
122
|
+
queue:,
|
123
|
+
poll_interval:
|
124
|
+
)
|
125
|
+
@connection = connection
|
126
|
+
@queue = queue
|
127
|
+
@poll_interval = poll_interval
|
128
|
+
@last_polled_at = nil
|
129
|
+
@last_poll_satisfied = nil
|
130
|
+
|
131
|
+
Que.internal_log :poller_instantiate, self do
|
132
|
+
{
|
133
|
+
backend_pid: connection.backend_pid,
|
134
|
+
queue: queue,
|
135
|
+
poll_interval: poll_interval,
|
136
|
+
}
|
137
|
+
end
|
138
|
+
end
|
139
|
+
|
140
|
+
def poll(
|
141
|
+
priorities:,
|
142
|
+
held_locks:
|
143
|
+
)
|
144
|
+
|
145
|
+
return unless should_poll?
|
146
|
+
|
147
|
+
expected_count = priorities.inject(0){|s,(p,c)| s + c}
|
148
|
+
|
149
|
+
jobs =
|
150
|
+
connection.execute_prepared(
|
151
|
+
:poll_jobs,
|
152
|
+
[
|
153
|
+
@queue,
|
154
|
+
"{#{held_locks.to_a.join(',')}}",
|
155
|
+
JSON.dump(priorities),
|
156
|
+
]
|
157
|
+
)
|
158
|
+
|
159
|
+
@last_polled_at = Time.now
|
160
|
+
@last_poll_satisfied = expected_count == jobs.count
|
161
|
+
|
162
|
+
Que.internal_log :poller_polled, self do
|
163
|
+
{
|
164
|
+
queue: @queue,
|
165
|
+
locked: jobs.count,
|
166
|
+
priorities: priorities,
|
167
|
+
held_locks: held_locks.to_a,
|
168
|
+
newly_locked: jobs.map { |key| key.fetch(:id) },
|
169
|
+
}
|
170
|
+
end
|
171
|
+
|
172
|
+
jobs.map! { |job| Metajob.new(job) }
|
173
|
+
end
|
174
|
+
|
175
|
+
def should_poll?
|
176
|
+
# Never polled before?
|
177
|
+
last_poll_satisfied.nil? ||
|
178
|
+
# Plenty of jobs were available last time?
|
179
|
+
last_poll_satisfied == true ||
|
180
|
+
poll_interval_elapsed?
|
181
|
+
end
|
182
|
+
|
183
|
+
def poll_interval_elapsed?
|
184
|
+
return unless interval = poll_interval
|
185
|
+
(Time.now - last_polled_at) > interval
|
186
|
+
end
|
187
|
+
|
188
|
+
class << self
|
189
|
+
# Manage some temporary infrastructure (specific to the connection) that
|
190
|
+
# we'll use for polling. These could easily be created permanently in a
|
191
|
+
# migration, but that'd require another migration if we wanted to tweak
|
192
|
+
# them later.
|
193
|
+
|
194
|
+
def setup(connection)
|
195
|
+
connection.execute <<-SQL
|
196
|
+
-- Temporary composite type we need for our queries to work.
|
197
|
+
CREATE TYPE pg_temp.que_query_result AS (
|
198
|
+
locked boolean,
|
199
|
+
remaining_priorities jsonb
|
200
|
+
);
|
201
|
+
|
202
|
+
CREATE FUNCTION pg_temp.lock_and_update_priorities(priorities jsonb, job que_jobs)
|
203
|
+
RETURNS pg_temp.que_query_result
|
204
|
+
AS $$
|
205
|
+
WITH
|
206
|
+
-- Take the lock in a CTE because we want to use the result
|
207
|
+
-- multiple times while only taking the lock once.
|
208
|
+
lock_taken AS (
|
209
|
+
SELECT pg_try_advisory_lock((job).id) AS taken
|
210
|
+
),
|
211
|
+
relevant AS (
|
212
|
+
SELECT priority, count
|
213
|
+
FROM (
|
214
|
+
SELECT
|
215
|
+
key::smallint AS priority,
|
216
|
+
value::text::integer AS count
|
217
|
+
FROM jsonb_each(priorities)
|
218
|
+
) t1
|
219
|
+
WHERE priority >= (job).priority
|
220
|
+
ORDER BY priority ASC
|
221
|
+
LIMIT 1
|
222
|
+
)
|
223
|
+
SELECT
|
224
|
+
(SELECT taken FROM lock_taken), -- R
|
225
|
+
CASE (SELECT taken FROM lock_taken)
|
226
|
+
WHEN false THEN
|
227
|
+
-- Simple case - we couldn't lock the job, so don't update the
|
228
|
+
-- priorities hash.
|
229
|
+
priorities
|
230
|
+
WHEN true THEN
|
231
|
+
CASE count
|
232
|
+
WHEN 1 THEN
|
233
|
+
-- Remove the priority from the JSONB doc entirely, rather
|
234
|
+
-- than leaving a zero entry in it.
|
235
|
+
priorities - priority::text
|
236
|
+
ELSE
|
237
|
+
-- Decrement the value in the JSONB doc.
|
238
|
+
jsonb_set(
|
239
|
+
priorities,
|
240
|
+
ARRAY[priority::text],
|
241
|
+
to_jsonb(count - 1)
|
242
|
+
)
|
243
|
+
END
|
244
|
+
END
|
245
|
+
FROM relevant
|
246
|
+
$$
|
247
|
+
STABLE
|
248
|
+
LANGUAGE SQL;
|
249
|
+
|
250
|
+
CREATE FUNCTION pg_temp.que_highest_remaining_priority(priorities jsonb) RETURNS smallint AS $$
|
251
|
+
SELECT max(key::smallint) FROM jsonb_each(priorities)
|
252
|
+
$$
|
253
|
+
STABLE
|
254
|
+
LANGUAGE SQL;
|
255
|
+
SQL
|
256
|
+
end
|
257
|
+
|
258
|
+
def cleanup(connection)
|
259
|
+
connection.execute <<-SQL
|
260
|
+
DROP FUNCTION pg_temp.que_highest_remaining_priority(jsonb);
|
261
|
+
DROP FUNCTION pg_temp.lock_and_update_priorities(jsonb, que_jobs);
|
262
|
+
DROP TYPE pg_temp.que_query_result;
|
263
|
+
SQL
|
264
|
+
end
|
265
|
+
end
|
266
|
+
end
|
267
|
+
end
|