que 0.5.0 → 0.6.0

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 (48) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +1 -1
  3. data/.travis.yml +1 -1
  4. data/CHANGELOG.md +21 -1
  5. data/Gemfile +5 -0
  6. data/README.md +7 -6
  7. data/docs/advanced_setup.md +14 -4
  8. data/docs/customizing_que.md +4 -4
  9. data/docs/error_handling.md +13 -1
  10. data/docs/managing_workers.md +2 -2
  11. data/docs/migrating.md +26 -0
  12. data/docs/multiple_queues.md +13 -0
  13. data/docs/shutting_down_safely.md +7 -0
  14. data/docs/writing_reliable_jobs.md +43 -0
  15. data/lib/generators/que/templates/add_que.rb +1 -1
  16. data/lib/que.rb +27 -41
  17. data/lib/que/adapters/base.rb +75 -4
  18. data/lib/que/job.rb +45 -28
  19. data/lib/que/migrations.rb +3 -2
  20. data/lib/que/migrations/{1-down.sql → 1/down.sql} +0 -0
  21. data/lib/que/migrations/{1-up.sql → 1/up.sql} +0 -0
  22. data/lib/que/migrations/{2-down.sql → 2/down.sql} +0 -0
  23. data/lib/que/migrations/{2-up.sql → 2/up.sql} +0 -0
  24. data/lib/que/migrations/3/down.sql +5 -0
  25. data/lib/que/migrations/3/up.sql +5 -0
  26. data/lib/que/sql.rb +24 -17
  27. data/lib/que/version.rb +1 -1
  28. data/lib/que/worker.rb +6 -5
  29. data/spec/adapters/active_record_spec.rb +6 -6
  30. data/spec/adapters/sequel_spec.rb +4 -4
  31. data/spec/gemfiles/Gemfile1 +18 -0
  32. data/spec/gemfiles/Gemfile2 +18 -0
  33. data/spec/support/helpers.rb +2 -1
  34. data/spec/support/shared_examples/adapter.rb +7 -3
  35. data/spec/support/shared_examples/multi_threaded_adapter.rb +2 -2
  36. data/spec/travis.rb +12 -4
  37. data/spec/unit/customization_spec.rb +148 -0
  38. data/spec/unit/{queue_spec.rb → enqueue_spec.rb} +115 -14
  39. data/spec/unit/logging_spec.rb +3 -2
  40. data/spec/unit/migrations_spec.rb +3 -2
  41. data/spec/unit/pool_spec.rb +30 -6
  42. data/spec/unit/run_spec.rb +12 -0
  43. data/spec/unit/states_spec.rb +29 -31
  44. data/spec/unit/stats_spec.rb +16 -14
  45. data/spec/unit/work_spec.rb +120 -25
  46. data/spec/unit/worker_spec.rb +55 -9
  47. data/tasks/safe_shutdown.rb +1 -1
  48. metadata +30 -17
@@ -1,3 +1,5 @@
1
+ require 'time' # For Time.parse.
2
+
1
3
  module Que
2
4
  module Adapters
3
5
  autoload :ActiveRecord, 'que/adapters/active_record'
@@ -25,11 +27,35 @@ module Que
25
27
  false
26
28
  end
27
29
 
28
- def execute(*args)
30
+ def execute(command, params = [])
31
+ params = params.map do |param|
32
+ case param
33
+ # The pg gem unfortunately doesn't convert fractions of time instances, so cast them to a string.
34
+ when Time then param.strftime("%Y-%m-%d %H:%M:%S.%6N %z")
35
+ when Array, Hash then JSON_MODULE.dump(param)
36
+ else param
37
+ end
38
+ end
39
+
40
+ cast_result \
41
+ case command
42
+ when Symbol then execute_prepared(command, params)
43
+ when String then execute_sql(command, params)
44
+ end
45
+ end
46
+
47
+ def in_transaction?
48
+ checkout { |conn| conn.transaction_status != ::PG::PQTRANS_IDLE }
49
+ end
50
+
51
+ private
52
+
53
+ def execute_sql(sql, params)
54
+ args = params.empty? ? [sql] : [sql, params]
29
55
  checkout { |conn| conn.async_exec(*args) }
30
56
  end
31
57
 
32
- def execute_prepared(name, params = [])
58
+ def execute_prepared(name, params)
33
59
  checkout do |conn|
34
60
  statements = @prepared_statements[conn] ||= {}
35
61
 
@@ -42,8 +68,53 @@ module Que
42
68
  end
43
69
  end
44
70
 
45
- def in_transaction?
46
- checkout { |conn| conn.transaction_status != ::PG::PQTRANS_IDLE }
71
+ HASH_DEFAULT_PROC = proc { |hash, key| hash[key.to_s] if Symbol === key }
72
+
73
+ INDIFFERENTIATOR = proc do |object|
74
+ case object
75
+ when Array
76
+ object.each(&INDIFFERENTIATOR)
77
+ when Hash
78
+ object.default_proc = HASH_DEFAULT_PROC
79
+ object.each { |key, value| object[key] = INDIFFERENTIATOR.call(value) }
80
+ object
81
+ else
82
+ object
83
+ end
84
+ end
85
+
86
+ CAST_PROCS = {}
87
+
88
+ # Integer, bigint, smallint:
89
+ CAST_PROCS[23] = CAST_PROCS[20] = CAST_PROCS[21] = proc(&:to_i)
90
+
91
+ # Timestamp with time zone.
92
+ CAST_PROCS[1184] = Time.method(:parse)
93
+
94
+ # JSON.
95
+ CAST_PROCS[114] = JSON_MODULE.method(:load)
96
+
97
+ # Boolean:
98
+ CAST_PROCS[16] = 't'.method(:==)
99
+
100
+ def cast_result(result)
101
+ output = result.to_a
102
+
103
+ result.fields.each_with_index do |field, index|
104
+ if converter = CAST_PROCS[result.ftype(index)]
105
+ output.each do |hash|
106
+ unless (value = hash[field]).nil?
107
+ hash[field] = converter.call(value)
108
+ end
109
+ end
110
+ end
111
+ end
112
+
113
+ if result.first.respond_to?(:with_indifferent_access)
114
+ output.map(&:with_indifferent_access)
115
+ else
116
+ output.each(&INDIFFERENTIATOR)
117
+ end
47
118
  end
48
119
  end
49
120
  end
data/lib/que/job.rb CHANGED
@@ -3,12 +3,11 @@ module Que
3
3
  attr_reader :attrs
4
4
 
5
5
  def initialize(attrs)
6
- @attrs = attrs
7
- @attrs[:args] = Que.indifferentiate JSON_MODULE.load(@attrs[:args])
6
+ @attrs = attrs
8
7
  end
9
8
 
10
9
  # Subclasses should define their own run methods, but keep an empty one
11
- # here so that Que::Job.queue can queue an empty job in testing.
10
+ # here so that Que::Job.enqueue can queue an empty job in testing.
12
11
  def run(*args)
13
12
  end
14
13
 
@@ -20,45 +19,61 @@ module Que
20
19
  private
21
20
 
22
21
  def destroy
23
- Que.execute :destroy_job, attrs.values_at(:priority, :run_at, :job_id)
22
+ Que.execute :destroy_job, attrs.values_at(:queue, :priority, :run_at, :job_id)
24
23
  @destroyed = true
25
24
  end
26
25
 
26
+ @retry_interval = proc { |count| count ** 4 + 3 }
27
+
27
28
  class << self
28
- def queue(*args)
29
+ attr_reader :retry_interval
30
+
31
+ def enqueue(*args)
29
32
  if args.last.is_a?(Hash)
30
- options = args.pop
31
- run_at = options.delete(:run_at)
32
- priority = options.delete(:priority)
33
+ options = args.pop
34
+ queue = options.delete(:queue) || '' if options.key?(:queue)
35
+ job_class = options.delete(:job_class)
36
+ run_at = options.delete(:run_at)
37
+ priority = options.delete(:priority)
33
38
  args << options if options.any?
34
39
  end
35
40
 
36
- attrs = {:job_class => to_s, :args => JSON_MODULE.dump(args)}
41
+ attrs = {:job_class => job_class || to_s, :args => args}
42
+
43
+ if t = run_at || @run_at && @run_at.call || @default_run_at && @default_run_at.call
44
+ attrs[:run_at] = t
45
+ end
37
46
 
38
- if time = run_at || @default_run_at && @default_run_at.call
39
- attrs[:run_at] = time
47
+ if p = priority || @priority || @default_priority
48
+ attrs[:priority] = p
40
49
  end
41
50
 
42
- if pty = priority || @default_priority
43
- attrs[:priority] = pty
51
+ if q = queue || @queue
52
+ attrs[:queue] = q
44
53
  end
45
54
 
46
- if Que.mode == :sync && !time
47
- run_job(attrs)
55
+ if Que.mode == :sync && !t
56
+ run(*attrs[:args])
48
57
  else
49
- values = Que.execute(:insert_job, attrs.values_at(:priority, :run_at, :job_class, :args)).first
50
- Que.adapter.wake_worker_after_commit unless time
58
+ values = Que.execute(:insert_job, attrs.values_at(:queue, :priority, :run_at, :job_class, :args)).first
59
+ Que.adapter.wake_worker_after_commit unless t
51
60
  new(values)
52
61
  end
53
62
  end
54
63
 
55
- def work
64
+ alias queue enqueue
65
+
66
+ def run(*args)
67
+ new(:args => args).tap(&:_run)
68
+ end
69
+
70
+ def work(queue = '')
56
71
  # Since we're taking session-level advisory locks, we have to hold the
57
72
  # same connection throughout the process of getting a job, working it,
58
73
  # deleting it, and removing the lock.
59
74
  Que.adapter.checkout do
60
75
  begin
61
- if job = Que.execute(:lock_job).first
76
+ if job = Que.execute(:lock_job, [queue]).first
62
77
  # Edge case: It's possible for the lock_job query to have
63
78
  # grabbed a job that's already been worked, if it took its MVCC
64
79
  # snapshot while the job was processing, but didn't attempt the
@@ -69,10 +84,12 @@ module Que
69
84
  # Note that there is currently no spec for this behavior, since
70
85
  # I'm not sure how to reliably commit a transaction that deletes
71
86
  # the job in a separate thread between lock_job and check_job.
72
- if Que.execute(:check_job, job.values_at(:priority, :run_at, :job_id)).none?
87
+ if Que.execute(:check_job, job.values_at(:queue, :priority, :run_at, :job_id)).none?
73
88
  {:event => :job_race_condition}
74
89
  else
75
- {:event => :job_worked, :job => run_job(job).attrs}
90
+ klass = class_for(job[:job_class])
91
+ klass.new(job)._run
92
+ {:event => :job_worked, :job => job}
76
93
  end
77
94
  else
78
95
  {:event => :job_unavailable}
@@ -80,11 +97,11 @@ module Que
80
97
  rescue => error
81
98
  begin
82
99
  if job
83
- # Borrowed the backoff formula and error data format from delayed_job.
84
- count = job[:error_count].to_i + 1
85
- delay = count ** 4 + 3
86
- message = "#{error.message}\n#{error.backtrace.join("\n")}"
87
- Que.execute :set_error, [count, delay, message] + job.values_at(:priority, :run_at, :job_id)
100
+ count = job[:error_count].to_i + 1
101
+ interval = klass && klass.retry_interval || retry_interval
102
+ delay = interval.respond_to?(:call) ? interval.call(count) : interval
103
+ message = "#{error.message}\n#{error.backtrace.join("\n")}"
104
+ Que.execute :set_error, [count, delay, message] + job.values_at(:queue, :priority, :run_at, :job_id)
88
105
  end
89
106
  rescue
90
107
  # If we can't reach the database for some reason, too bad, but
@@ -111,8 +128,8 @@ module Que
111
128
 
112
129
  private
113
130
 
114
- def run_job(attrs)
115
- attrs[:job_class].split('::').inject(Object, &:const_get).new(attrs).tap(&:_run)
131
+ def class_for(string)
132
+ string.split('::').inject(Object, &:const_get)
116
133
  end
117
134
  end
118
135
  end
@@ -3,7 +3,7 @@ module Que
3
3
  # In order to ship a schema change, add the relevant up and down sql files
4
4
  # to the migrations directory, and bump the version both here and in the
5
5
  # add_que generator template.
6
- CURRENT_VERSION = 2
6
+ CURRENT_VERSION = 3
7
7
 
8
8
  class << self
9
9
  def migrate!(options = {:version => CURRENT_VERSION})
@@ -21,7 +21,8 @@ module Que
21
21
  end
22
22
 
23
23
  steps.each do |step|
24
- Que.execute File.read("#{File.dirname(__FILE__)}/migrations/#{step}-#{direction}.sql")
24
+ sql = File.read("#{File.dirname(__FILE__)}/migrations/#{step}/#{direction}.sql")
25
+ Que.execute(sql)
25
26
  end
26
27
 
27
28
  set_db_version(version)
File without changes
File without changes
File without changes
File without changes
@@ -0,0 +1,5 @@
1
+ ALTER TABLE que_jobs
2
+ DROP CONSTRAINT que_jobs_pkey,
3
+ DROP COLUMN queue,
4
+ ALTER COLUMN priority TYPE integer,
5
+ ADD CONSTRAINT que_jobs_pkey PRIMARY KEY (priority, run_at, job_id);
@@ -0,0 +1,5 @@
1
+ ALTER TABLE que_jobs
2
+ DROP CONSTRAINT que_jobs_pkey,
3
+ ALTER COLUMN priority TYPE smallint,
4
+ ADD COLUMN queue TEXT NOT NULL DEFAULT '',
5
+ ADD CONSTRAINT que_jobs_pkey PRIMARY KEY (queue, priority, run_at, job_id);
data/lib/que/sql.rb CHANGED
@@ -7,7 +7,8 @@ module Que
7
7
  FROM (
8
8
  SELECT j
9
9
  FROM que_jobs AS j
10
- WHERE run_at <= now()
10
+ WHERE queue = $1::text
11
+ AND run_at <= now()
11
12
  ORDER BY priority, run_at, job_id
12
13
  LIMIT 1
13
14
  ) AS t1
@@ -17,7 +18,9 @@ module Que
17
18
  SELECT (
18
19
  SELECT j
19
20
  FROM que_jobs AS j
20
- WHERE run_at <= now() AND (priority, run_at, job_id) > (job.priority, job.run_at, job.job_id)
21
+ WHERE queue = $1::text
22
+ AND run_at <= now()
23
+ AND (priority, run_at, job_id) > (job.priority, job.run_at, job.job_id)
21
24
  ORDER BY priority, run_at, job_id
22
25
  LIMIT 1
23
26
  ) AS j
@@ -27,7 +30,7 @@ module Que
27
30
  ) AS t1
28
31
  )
29
32
  )
30
- SELECT priority, run_at, job_id, job_class, args, error_count
33
+ SELECT queue, priority, run_at, job_id, job_class, args, error_count
31
34
  FROM job
32
35
  WHERE locked
33
36
  LIMIT 1
@@ -36,9 +39,10 @@ module Que
36
39
  :check_job => %{
37
40
  SELECT 1 AS one
38
41
  FROM que_jobs
39
- WHERE priority = $1::integer
40
- AND run_at = $2::timestamptz
41
- AND job_id = $3::bigint
42
+ WHERE queue = $1::text
43
+ AND priority = $2::smallint
44
+ AND run_at = $3::timestamptz
45
+ AND job_id = $4::bigint
42
46
  }.freeze,
43
47
 
44
48
  :set_error => %{
@@ -46,28 +50,31 @@ module Que
46
50
  SET error_count = $1::integer,
47
51
  run_at = now() + $2::integer * '1 second'::interval,
48
52
  last_error = $3::text
49
- WHERE priority = $4::integer
50
- AND run_at = $5::timestamptz
51
- AND job_id = $6::bigint
53
+ WHERE queue = $4::text
54
+ AND priority = $5::smallint
55
+ AND run_at = $6::timestamptz
56
+ AND job_id = $7::bigint
52
57
  }.freeze,
53
58
 
54
59
  :insert_job => %{
55
60
  INSERT INTO que_jobs
56
- (priority, run_at, job_class, args)
61
+ (queue, priority, run_at, job_class, args)
57
62
  VALUES
58
- (coalesce($1, 100)::integer, coalesce($2, 'now')::timestamptz, $3::text, coalesce($4, '[]')::json)
63
+ (coalesce($1, '')::text, coalesce($2, 100)::smallint, coalesce($3, 'now')::timestamptz, $4::text, coalesce($5, '[]')::json)
59
64
  RETURNING *
60
65
  }.freeze,
61
66
 
62
67
  :destroy_job => %{
63
68
  DELETE FROM que_jobs
64
- WHERE priority = $1::integer
65
- AND run_at = $2::timestamptz
66
- AND job_id = $3::bigint
69
+ WHERE queue = $1::text
70
+ AND priority = $2::smallint
71
+ AND run_at = $3::timestamptz
72
+ AND job_id = $4::bigint
67
73
  }.freeze,
68
74
 
69
75
  :job_stats => %{
70
- SELECT job_class,
76
+ SELECT queue,
77
+ job_class,
71
78
  count(*) AS count,
72
79
  count(locks.job_id) AS count_working,
73
80
  sum((error_count > 0)::int) AS count_errored,
@@ -79,7 +86,7 @@ module Que
79
86
  FROM pg_locks
80
87
  WHERE locktype = 'advisory'
81
88
  ) locks USING (job_id)
82
- GROUP BY job_class
89
+ GROUP BY queue, job_class
83
90
  ORDER BY count(*) DESC
84
91
  }.freeze,
85
92
 
@@ -100,5 +107,5 @@ module Que
100
107
  WHERE locktype = 'advisory'
101
108
  ) pg USING (job_id)
102
109
  }.freeze
103
- }
110
+ }.freeze
104
111
  end
data/lib/que/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Que
2
- Version = '0.5.0'
2
+ Version = '0.6.0'
3
3
  end
data/lib/que/worker.rb CHANGED
@@ -8,10 +8,11 @@ module Que
8
8
  # synchronize access to it.
9
9
  include MonitorMixin
10
10
 
11
- attr_reader :thread, :state
11
+ attr_reader :thread, :state, :queue
12
12
 
13
- def initialize
14
- super # For MonitorMixin.
13
+ def initialize(queue = '')
14
+ super() # For MonitorMixin.
15
+ @queue = queue
15
16
  @state = :working
16
17
  @thread = Thread.new { work_loop }
17
18
  end
@@ -71,7 +72,7 @@ module Que
71
72
  loop do
72
73
  time = Time.now
73
74
  cycle = nil
74
- result = Job.work
75
+ result = Job.work(queue)
75
76
 
76
77
  case result[:event]
77
78
  when :job_unavailable
@@ -167,7 +168,7 @@ module Que
167
168
  Que.log :event => 'worker_count_change', :value => count.to_s
168
169
 
169
170
  if count > worker_count
170
- workers.push *(count - worker_count).times.map{new}
171
+ workers.push *(count - worker_count).times.map{new(ENV['QUE_QUEUE'] || '')}
171
172
  elsif count < worker_count
172
173
  workers.pop(worker_count - count).each(&:stop).each(&:wait_until_stopped)
173
174
  end
@@ -22,7 +22,7 @@ unless defined?(RUBY_ENGINE) && RUBY_ENGINE == 'jruby'
22
22
  end
23
23
  end
24
24
 
25
- ActiveRecordJob.queue
25
+ ActiveRecordJob.enqueue
26
26
  Que::Job.work
27
27
 
28
28
  $pid1.should == $pid2
@@ -32,7 +32,7 @@ unless defined?(RUBY_ENGINE) && RUBY_ENGINE == 'jruby'
32
32
  end
33
33
 
34
34
  it "should instantiate args as ActiveSupport::HashWithIndifferentAccess" do
35
- ArgsJob.queue :param => 2
35
+ ArgsJob.enqueue :param => 2
36
36
  Que::Job.work
37
37
  $passed_args.first[:param].should == 2
38
38
  $passed_args.first.should be_an_instance_of ActiveSupport::HashWithIndifferentAccess
@@ -42,7 +42,7 @@ unless defined?(RUBY_ENGINE) && RUBY_ENGINE == 'jruby'
42
42
  Que.mode = :async
43
43
  sleep_until { Que::Worker.workers.all? &:sleeping? }
44
44
 
45
- Que::Job.queue :run_at => 1.minute.ago
45
+ Que::Job.enqueue :run_at => 1.minute.ago
46
46
  DB[:que_jobs].get(:run_at).should be_within(3).of Time.now - 60
47
47
 
48
48
  Que.wake_interval = 0.005.seconds
@@ -54,17 +54,17 @@ unless defined?(RUBY_ENGINE) && RUBY_ENGINE == 'jruby'
54
54
  sleep_until { Que::Worker.workers.all? &:sleeping? }
55
55
 
56
56
  # Wakes a worker immediately when not in a transaction.
57
- Que::Job.queue
57
+ Que::Job.enqueue
58
58
  sleep_until { Que::Worker.workers.all?(&:sleeping?) && DB[:que_jobs].empty? }
59
59
 
60
60
  ActiveRecord::Base.transaction do
61
- Que::Job.queue
61
+ Que::Job.enqueue
62
62
  Que::Worker.workers.each { |worker| worker.should be_sleeping }
63
63
  end
64
64
  sleep_until { Que::Worker.workers.all?(&:sleeping?) && DB[:que_jobs].empty? }
65
65
 
66
66
  # Do nothing when queueing with a specific :run_at.
67
- BlockJob.queue :run_at => Time.now
67
+ BlockJob.enqueue :run_at => Time.now
68
68
  Que::Worker.workers.each { |worker| worker.should be_sleeping }
69
69
  end
70
70