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,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
@@ -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 both here and in the
7
- # add_que generator template.
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!(options = {:version => CURRENT_VERSION})
10
+ def migrate!(version:)
12
11
  Que.transaction do
13
- version = options[:version]
12
+ current = db_version
14
13
 
15
- if (current = db_version) == version
14
+ if current == version
16
15
  return
17
16
  elsif current < version
18
- direction = 'up'
17
+ direction = :up
19
18
  steps = ((current + 1)..version).to_a
20
19
  elsif current > version
21
- direction = 'down'
20
+ direction = :down
22
21
  steps = ((version + 1)..current).to_a.reverse
23
22
  end
24
23
 
25
24
  steps.each do |step|
26
- sql = File.read("#{File.dirname(__FILE__)}/migrations/#{step}/#{direction}.sql")
27
- Que.execute(sql)
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 = Que.execute <<-SQL
36
- SELECT relname, description
37
- FROM pg_class
38
- LEFT JOIN pg_description ON pg_description.objoid = pg_class.oid
39
- WHERE relname = 'que_jobs'
40
- SQL
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 existed.
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();
@@ -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