queue_classic 3.2.0.RC1 → 4.0.0.pre.alpha1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (41) hide show
  1. checksums.yaml +5 -5
  2. data/.circleci/config.yml +151 -0
  3. data/.gitignore +2 -0
  4. data/{changelog → CHANGELOG.md} +80 -34
  5. data/CODE_OF_CONDUCT.md +46 -0
  6. data/Gemfile +8 -5
  7. data/README.md +77 -85
  8. data/Rakefile +2 -0
  9. data/lib/generators/queue_classic/install_generator.rb +6 -0
  10. data/lib/generators/queue_classic/templates/add_queue_classic.rb +3 -1
  11. data/lib/generators/queue_classic/templates/update_queue_classic_3_0_0.rb +3 -1
  12. data/lib/generators/queue_classic/templates/update_queue_classic_3_0_2.rb +3 -1
  13. data/lib/generators/queue_classic/templates/update_queue_classic_3_1_0.rb +3 -1
  14. data/lib/generators/queue_classic/templates/update_queue_classic_4_0_0.rb +11 -0
  15. data/lib/queue_classic.rb +4 -11
  16. data/lib/queue_classic/config.rb +2 -1
  17. data/lib/queue_classic/conn_adapter.rb +28 -14
  18. data/lib/queue_classic/queue.rb +65 -11
  19. data/lib/queue_classic/railtie.rb +2 -0
  20. data/lib/queue_classic/setup.rb +24 -7
  21. data/lib/queue_classic/tasks.rb +4 -5
  22. data/lib/queue_classic/version.rb +3 -1
  23. data/lib/queue_classic/worker.rb +10 -5
  24. data/queue_classic.gemspec +1 -1
  25. data/sql/create_table.sql +7 -16
  26. data/sql/ddl.sql +6 -82
  27. data/sql/downgrade_from_4_0_0.sql +88 -0
  28. data/sql/update_to_3_0_0.sql +5 -5
  29. data/sql/update_to_3_1_0.sql +6 -6
  30. data/sql/update_to_4_0_0.sql +6 -0
  31. data/test/benchmark_test.rb +2 -0
  32. data/test/config_test.rb +2 -0
  33. data/test/helper.rb +34 -0
  34. data/test/lib/queue_classic_rails_connection_test.rb +9 -6
  35. data/test/lib/queue_classic_test.rb +2 -0
  36. data/test/queue_test.rb +62 -2
  37. data/test/rails-tests/.gitignore +2 -0
  38. data/test/rails-tests/rails523.sh +23 -0
  39. data/test/worker_test.rb +138 -17
  40. metadata +15 -7
  41. data/.travis.yml +0 -15
@@ -0,0 +1,88 @@
1
+ DO $$DECLARE r record;
2
+ BEGIN
3
+ -- If jsonb type is available, do nothing as we're downgrading from 4.0.0
4
+ IF EXISTS (SELECT 1 FROM pg_type WHERE typname = 'jsonb') THEN
5
+ -- do nothing - it should already be already jsonb
6
+ -- Otherwise, use json type for the args column if available
7
+ ELSIF EXISTS (SELECT 1 FROM pg_type WHERE typname = 'json') THEN
8
+ -- this should only happen if someone downgrades QC and their database < pg 9.4
9
+ ALTER TABLE queue_classic_jobs ALTER COLUMN args TYPE json USING args::json;
10
+ END IF;
11
+
12
+
13
+ END$$;
14
+
15
+
16
+ --
17
+ -- Re install the lock_head function
18
+ --
19
+
20
+ -- We are declaring the return type to be queue_classic_jobs.
21
+ -- This is ok since I am assuming that all of the users added queues will
22
+ -- have identical columns to queue_classic_jobs.
23
+ -- When QC supports queues with columns other than the default, we will have to change this.
24
+
25
+ CREATE OR REPLACE FUNCTION lock_head(q_name varchar, top_boundary integer)
26
+ RETURNS SETOF queue_classic_jobs AS $$
27
+ DECLARE
28
+ unlocked bigint;
29
+ relative_top integer;
30
+ job_count integer;
31
+ BEGIN
32
+ -- The purpose is to release contention for the first spot in the table.
33
+ -- The select count(*) is going to slow down dequeue performance but allow
34
+ -- for more workers. Would love to see some optimization here...
35
+
36
+ EXECUTE 'SELECT count(*) FROM '
37
+ || '(SELECT * FROM queue_classic_jobs '
38
+ || ' WHERE locked_at IS NULL'
39
+ || ' AND q_name = '
40
+ || quote_literal(q_name)
41
+ || ' AND scheduled_at <= '
42
+ || quote_literal(now())
43
+ || ' LIMIT '
44
+ || quote_literal(top_boundary)
45
+ || ') limited'
46
+ INTO job_count;
47
+
48
+ SELECT TRUNC(random() * (top_boundary - 1))
49
+ INTO relative_top;
50
+
51
+ IF job_count < top_boundary THEN
52
+ relative_top = 0;
53
+ END IF;
54
+
55
+ LOOP
56
+ BEGIN
57
+ EXECUTE 'SELECT id FROM queue_classic_jobs '
58
+ || ' WHERE locked_at IS NULL'
59
+ || ' AND q_name = '
60
+ || quote_literal(q_name)
61
+ || ' AND scheduled_at <= '
62
+ || quote_literal(now())
63
+ || ' ORDER BY id ASC'
64
+ || ' LIMIT 1'
65
+ || ' OFFSET ' || quote_literal(relative_top)
66
+ || ' FOR UPDATE NOWAIT'
67
+ INTO unlocked;
68
+ EXIT;
69
+ EXCEPTION
70
+ WHEN lock_not_available THEN
71
+ -- do nothing. loop again and hope we get a lock
72
+ END;
73
+ END LOOP;
74
+
75
+ RETURN QUERY EXECUTE 'UPDATE queue_classic_jobs '
76
+ || ' SET locked_at = (CURRENT_TIMESTAMP),'
77
+ || ' locked_by = (select pg_backend_pid())'
78
+ || ' WHERE id = $1'
79
+ || ' AND locked_at is NULL'
80
+ || ' RETURNING *'
81
+ USING unlocked;
82
+
83
+ RETURN;
84
+ END $$ LANGUAGE plpgsql;
85
+
86
+ CREATE OR REPLACE FUNCTION lock_head(tname varchar) RETURNS SETOF queue_classic_jobs AS $$ BEGIN
87
+ RETURN QUERY EXECUTE 'SELECT * FROM lock_head($1,10)' USING tname;
88
+ END $$ LANGUAGE plpgsql;
@@ -1,10 +1,10 @@
1
1
  DO $$DECLARE r record;
2
2
  BEGIN
3
- BEGIN
4
- ALTER TABLE queue_classic_jobs ADD COLUMN created_at timestamptz default now();
5
- EXCEPTION
6
- WHEN duplicate_column THEN RAISE NOTICE 'column created_at already exists in queue_classic_jobs.';
7
- END;
3
+ BEGIN
4
+ ALTER TABLE queue_classic_jobs ADD COLUMN created_at timestamptz DEFAULT now();
5
+ EXCEPTION
6
+ WHEN duplicate_column THEN RAISE NOTICE 'column created_at already exists in queue_classic_jobs.';
7
+ END;
8
8
  END$$;
9
9
 
10
10
  DO $$DECLARE r record;
@@ -1,9 +1,9 @@
1
1
  DO $$DECLARE r record;
2
2
  BEGIN
3
- BEGIN
4
- ALTER TABLE queue_classic_jobs ADD COLUMN scheduled_at timestamptz default now();
5
- CREATE INDEX idx_qc_on_scheduled_at_only_unlocked ON queue_classic_jobs (scheduled_at, id) WHERE locked_at IS NULL;
6
- EXCEPTION
7
- WHEN duplicate_column THEN RAISE NOTICE 'column scheduled_at already exists in queue_classic_jobs.';
8
- END;
3
+ BEGIN
4
+ ALTER TABLE queue_classic_jobs ADD COLUMN scheduled_at timestamptz DEFAULT now();
5
+ CREATE INDEX idx_qc_on_scheduled_at_only_unlocked ON queue_classic_jobs (scheduled_at, id) WHERE locked_at IS NULL;
6
+ EXCEPTION
7
+ WHEN duplicate_column THEN RAISE NOTICE 'column scheduled_at already exists in queue_classic_jobs.';
8
+ END;
9
9
  END$$;
@@ -0,0 +1,6 @@
1
+ DO $$DECLARE r record;
2
+ BEGIN
3
+ ALTER TABLE queue_classic_jobs ALTER COLUMN args TYPE jsonb USING args::jsonb;
4
+ DROP FUNCTION IF EXISTS lock_head(tname varchar);
5
+ DROP FUNCTION IF EXISTS lock_head(q_name varchar, top_boundary integer);
6
+ END$$;
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require_relative 'helper'
2
4
 
3
5
  if ENV["QC_BENCHMARK"]
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require_relative 'helper'
2
4
 
3
5
  class ConfigTest < QCTest
@@ -1,6 +1,16 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "bundler"
4
+ require "minitest/reporters"
5
+
2
6
  Bundler.setup :default, :test
3
7
 
8
+ if ENV['CIRCLECI'] == "true"
9
+ Minitest::Reporters.use! Minitest::Reporters::JUnitReporter.new
10
+ else
11
+ Minitest::Reporters.use! Minitest::Reporters::SpecReporter.new
12
+ end
13
+
4
14
  ENV["DATABASE_URL"] ||= "postgres:///queue_classic_test"
5
15
 
6
16
  require_relative '../lib/queue_classic'
@@ -58,4 +68,28 @@ class QCTest < Minitest::Test
58
68
  ensure
59
69
  original_environment.each { |name, value| ENV[name] = value }
60
70
  end
71
+
72
+ def stub_any_instance(class_name, method_name, definition)
73
+ new_method_name = "new_#{method_name}"
74
+ original_method_name = "original_#{method_name}"
75
+
76
+ method_present = class_name.instance_methods(false).include? method_name
77
+
78
+ if method_present
79
+ class_name.send(:alias_method, original_method_name, method_name)
80
+ class_name.send(:define_method, new_method_name, definition)
81
+ class_name.send(:alias_method, method_name, new_method_name)
82
+
83
+ yield
84
+ else
85
+ message = "#{class_name} does not have method #{method_name}."
86
+ message << "\nAvailable methods: #{class_name.instance_methods(false)}"
87
+ raise ArgumentError.new message
88
+ end
89
+ ensure
90
+ if method_present
91
+ class_name.send(:alias_method, method_name, original_method_name)
92
+ class_name.send(:undef_method, new_method_name)
93
+ end
94
+ end
61
95
  end
@@ -1,15 +1,14 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require File.expand_path("../../helper.rb", __FILE__)
2
4
 
3
5
  class QueueClassicRailsConnectionTest < QCTest
4
6
  def before_setup
5
- Object.send :const_set, :ActiveRecord, Module.new
6
- ActiveRecord.const_set :Base, Module.new
7
-
8
7
  @original_conn_adapter = QC.default_conn_adapter
9
8
  QC.default_conn_adapter = nil
10
9
  end
11
10
 
12
- def after_teardown
11
+ def before_teardown
13
12
  ActiveRecord.send :remove_const, :Base
14
13
  Object.send :remove_const, :ActiveRecord
15
14
 
@@ -18,12 +17,14 @@ class QueueClassicRailsConnectionTest < QCTest
18
17
 
19
18
  def test_uses_active_record_connection_if_exists
20
19
  connection = get_connection
21
- assert connection.verify
20
+ QC.default_conn_adapter.execute('SELECT 1;')
21
+ connection.verify
22
22
  end
23
23
 
24
24
  def test_does_not_use_active_record_connection_if_env_var_set
25
25
  with_env 'QC_RAILS_DATABASE' => 'false' do
26
26
  connection = get_connection
27
+ QC.default_conn_adapter.execute('SELECT 1;')
27
28
  assert_raises(MockExpectationError) { connection.verify }
28
29
  end
29
30
  end
@@ -31,8 +32,10 @@ class QueueClassicRailsConnectionTest < QCTest
31
32
  private
32
33
  def get_connection
33
34
  connection = Minitest::Mock.new
34
- connection.expect(:raw_connection, QC::ConnAdapter.new.connection)
35
+ connection.expect(:raw_connection, QC::ConnAdapter.new(active_record_connection_share: true).connection)
35
36
 
37
+ Object.send :const_set, :ActiveRecord, Module.new
38
+ ActiveRecord.const_set :Base, Module.new
36
39
  ActiveRecord::Base.define_singleton_method(:connection) do
37
40
  connection
38
41
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require File.expand_path("../../helper.rb", __FILE__)
2
4
 
3
5
  class QueueClassicTest < QCTest
@@ -1,8 +1,10 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require_relative 'helper'
2
4
 
3
5
  class QueueTest < QCTest
4
6
 
5
- ResetError = Class.new(PGError)
7
+ ResetError = Class.new(PG::Error)
6
8
 
7
9
  def test_enqueue
8
10
  QC.enqueue("Klass.method")
@@ -62,6 +64,20 @@ class QueueTest < QCTest
62
64
  def test_count
63
65
  QC.enqueue("Klass.method")
64
66
  assert_equal(1, QC.count)
67
+
68
+ QC.enqueue("Klass.method")
69
+ assert_equal(2, QC.count)
70
+ assert_equal(2, QC.count_ready)
71
+ assert_equal(0, QC.count_scheduled)
72
+
73
+ QC.enqueue_in(60, "Klass.method")
74
+ assert_equal(3, QC.count)
75
+ assert_equal(2, QC.count_ready)
76
+ assert_equal(1, QC.count_scheduled)
77
+
78
+ assert_raises(ArgumentError) do
79
+ QC.count(:potatoes)
80
+ end
65
81
  end
66
82
 
67
83
  def test_delete
@@ -105,13 +121,57 @@ class QueueTest < QCTest
105
121
  queue.enqueue("Klass.method")
106
122
  assert_equal(1, queue.count)
107
123
  conn = queue.conn_adapter.connection
108
- def conn.exec(*args); raise(PGError); end
124
+ def conn.exec(*args); raise(PG::Error); end
109
125
  def conn.reset(*args); raise(ResetError) end
110
126
  # We ensure that the reset method is called on the connection.
111
127
  assert_raises(PG::Error, ResetError) {queue.enqueue("Klass.other_method")}
112
128
  queue.conn_adapter.disconnect
113
129
  end
114
130
 
131
+ def test_enqueue_retry
132
+ queue = QC::Queue.new("queue_classic_jobs")
133
+ queue.conn_adapter = QC::ConnAdapter.new
134
+ conn = queue.conn_adapter.connection
135
+ conn.exec('select pg_terminate_backend(pg_backend_pid())') rescue nil
136
+ queue.enqueue("Klass.method")
137
+ assert_equal(1, queue.count)
138
+ queue.conn_adapter.disconnect
139
+ end
140
+
141
+ def test_enqueue_stops_retrying_on_permanent_error
142
+ queue = QC::Queue.new("queue_classic_jobs")
143
+ queue.conn_adapter = QC::ConnAdapter.new
144
+ conn = queue.conn_adapter.connection
145
+ conn.exec('select pg_terminate_backend(pg_backend_pid())') rescue nil
146
+ # Simulate permanent connection error
147
+ def conn.exec(*args); raise(PG::Error); end
148
+ # Ensure that the error is reraised on second time
149
+ assert_raises(PG::Error) {queue.enqueue("Klass.other_method")}
150
+ queue.conn_adapter.disconnect
151
+ end
152
+
153
+ def test_enqueue_in_retry
154
+ queue = QC::Queue.new("queue_classic_jobs")
155
+ queue.conn_adapter = QC::ConnAdapter.new
156
+ conn = queue.conn_adapter.connection
157
+ conn.exec('select pg_terminate_backend(pg_backend_pid())') rescue nil
158
+ queue.enqueue_in(10,"Klass.method")
159
+ assert_equal(1, queue.count)
160
+ queue.conn_adapter.disconnect
161
+ end
162
+
163
+ def test_enqueue_in_stops_retrying_on_permanent_error
164
+ queue = QC::Queue.new("queue_classic_jobs")
165
+ queue.conn_adapter = QC::ConnAdapter.new
166
+ conn = queue.conn_adapter.connection
167
+ conn.exec('select pg_terminate_backend(pg_backend_pid())') rescue nil
168
+ # Simulate permanent connection error
169
+ def conn.exec(*args); raise(PG::Error); end
170
+ # Ensure that the error is reraised on second time
171
+ assert_raises(PG::Error) {queue.enqueue_in(10,"Klass.method")}
172
+ queue.conn_adapter.disconnect
173
+ end
174
+
115
175
  def test_custom_default_queue
116
176
  queue_class = Class.new do
117
177
  attr_accessor :jobs
@@ -0,0 +1,2 @@
1
+ vendor/
2
+ qctest*/
@@ -0,0 +1,23 @@
1
+ #!/bin/bash
2
+ set -e
3
+
4
+ # remove any old folder, should only matter locally
5
+ rm -rf qctest523
6
+
7
+ # install rails but not with much stuff
8
+ gem install rails -v 5.2.3
9
+ rails new qctest523 --api --database=postgresql --skip-test-unit --skip-keeps --skip-spring --skip-sprockets --skip-javascript --skip-turbolinks
10
+ cd qctest523
11
+
12
+ # get the db setup, run any default migrations
13
+ bundle install
14
+ bundle exec rails db:drop:all
15
+ bundle exec rails db:create
16
+ bundle exec rails db:migrate
17
+ bundle exec rails db:setup
18
+
19
+ # install qc --> gem file, bundle, add ourselves and migrate.
20
+ echo "gem 'queue_classic', path: '../../../'" >> Gemfile
21
+ bundle install
22
+ bundle exec rails generate queue_classic:install
23
+ bundle exec rails db:migrate
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require_relative 'helper'
2
4
 
3
5
  module TestObject
@@ -121,16 +123,16 @@ class WorkerTest < QCTest
121
123
  t.join
122
124
  end
123
125
 
124
- def test_worker_uses_one_conn
125
- skip "This test is broken and needs to be fixed."
126
-
126
+ def test_worker_reuses_conn
127
127
  QC.enqueue("TestObject.no_args")
128
+ count = QC.default_conn_adapter.execute("SELECT count(*) from pg_stat_activity where datname = current_database()")["count"].to_i;
128
129
  worker = TestWorker.new
129
130
  worker.work
130
- assert_equal(
131
- 1,
132
- QC.default_conn_adapter.execute("SELECT count(*) from pg_stat_activity where datname = current_database()")["count"].to_i,
133
- "Multiple connections found -- are there open connections to #{ QC.default_conn_adapter.send(:db_url) } in other terminals?"
131
+
132
+ new_count = QC.default_conn_adapter.execute("SELECT count(*) from pg_stat_activity where datname = current_database()")["count"].to_i;
133
+ assert(
134
+ new_count == count,
135
+ "Worker should not initialize new connections to #{ QC.default_conn_adapter.send(:db_url) }."
134
136
  )
135
137
  end
136
138
 
@@ -176,15 +178,6 @@ class WorkerTest < QCTest
176
178
  assert_equal(0, worker.failed_count)
177
179
  end
178
180
 
179
- def test_init_worker_with_arg
180
- with_database 'postgres:///invalid' do
181
- conn = PG::Connection.connect(dbname: 'queue_classic_test')
182
- QC::Worker.new connection: conn
183
-
184
- conn.close
185
- end
186
- end
187
-
188
181
  def test_init_worker_with_database_url
189
182
  with_database ENV['DATABASE_URL'] || ENV['QC_DATABASE_URL'] do
190
183
  worker = QC::Worker.new
@@ -197,7 +190,135 @@ class WorkerTest < QCTest
197
190
 
198
191
  def test_init_worker_without_conn
199
192
  with_database nil do
200
- assert_raises(ArgumentError) { QC::Worker.new }
193
+ assert_raises(ArgumentError) do
194
+ worker = QC::Worker.new
195
+ QC.enqueue("TestObject.no_args")
196
+ worker.lock_job
197
+ end
198
+ end
199
+ end
200
+
201
+ def test_worker_unlocks_job_on_signal_exception
202
+ job_details = QC.enqueue("Kernel.eval", "raise SignalException.new('INT')")
203
+ worker = TestWorker.new
204
+
205
+ unlocked = nil
206
+
207
+ fake_unlock = Proc.new do |job_id|
208
+ if job_id == job_details['id']
209
+ unlocked = true
210
+ end
211
+ original_unlock(job_id)
212
+ end
213
+
214
+ stub_any_instance(QC::Queue, :unlock, fake_unlock) do
215
+ begin
216
+ worker.work
217
+ rescue SignalException
218
+ ensure
219
+ assert unlocked, "SignalException failed to unlock the job in the queue."
220
+ end
221
+ end
222
+ end
223
+
224
+ def test_worker_unlocks_job_on_system_exit
225
+ job_details = QC.enqueue("Kernel.eval", "raise SystemExit.new")
226
+ worker = TestWorker.new
227
+
228
+ unlocked = nil
229
+
230
+ fake_unlock = Proc.new do |job_id|
231
+ if job_id == job_details['id']
232
+ unlocked = true
233
+ end
234
+ original_unlock(job_id)
235
+ end
236
+
237
+ stub_any_instance(QC::Queue, :unlock, fake_unlock) do
238
+ begin
239
+ worker.work
240
+ rescue SystemExit
241
+ ensure
242
+ assert unlocked, "SystemExit failed to unlock the job in the queue."
243
+ end
244
+ end
245
+ end
246
+
247
+ def test_worker_does_not_unlock_jobs_on_syntax_error
248
+ job_details = QC.enqueue("Kernel.eval", "bad syntax")
249
+ worker = TestWorker.new
250
+
251
+ unlocked = nil
252
+
253
+ fake_unlock = Proc.new do |job_id|
254
+ if job_id == job_details['id']
255
+ unlocked = true
256
+ end
257
+ original_unlock(job_id)
258
+ end
259
+
260
+ stub_any_instance(QC::Queue, :unlock, fake_unlock) do
261
+ begin
262
+ errors = capture_stderr_output do
263
+ worker.work
264
+ end
265
+ ensure
266
+ message = ["SyntaxError unexpectedly unlocked the job in the queue."]
267
+ message << "Errors:\n#{errors}" unless errors.empty?
268
+ refute unlocked, message.join("\n")
269
+ end
270
+ end
271
+ end
272
+
273
+ def test_worker_does_not_unlock_jobs_on_load_error
274
+ job_details = QC.enqueue("Kernel.eval", "require 'not_a_real_file'")
275
+ worker = TestWorker.new
276
+
277
+ unlocked = nil
278
+
279
+ fake_unlock = Proc.new do |job_id|
280
+ if job_id == job_details['id']
281
+ unlocked = true
282
+ end
283
+ original_unlock(job_id)
284
+ end
285
+
286
+ stub_any_instance(QC::Queue, :unlock, fake_unlock) do
287
+ begin
288
+ errors = capture_stderr_output do
289
+ worker.work
290
+ end
291
+ ensure
292
+ message = ["LoadError unexpectedly unlocked the job in the queue."]
293
+ message << "Errors:\n#{errors}" unless errors.empty?
294
+ refute unlocked, message.join("\n")
295
+ end
296
+ end
297
+ end
298
+
299
+ def test_worker_does_not_unlock_jobs_on_no_memory_error
300
+ job_details = QC.enqueue("Kernel.eval", "raise NoMemoryError.new")
301
+ worker = TestWorker.new
302
+
303
+ unlocked = nil
304
+
305
+ fake_unlock = Proc.new do |job_id|
306
+ if job_id == job_details['id']
307
+ unlocked = true
308
+ end
309
+ original_unlock(job_id)
310
+ end
311
+
312
+ stub_any_instance(QC::Queue, :unlock, fake_unlock) do
313
+ begin
314
+ errors = capture_stderr_output do
315
+ worker.work
316
+ end
317
+ ensure
318
+ message = ["NoMemoryError unexpectedly unlocked the job in the queue."]
319
+ message << "Errors:\n#{errors}" unless errors.empty?
320
+ refute unlocked, message.join("\n")
321
+ end
201
322
  end
202
323
  end
203
324