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.
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