que 0.0.1 → 0.1.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.
@@ -0,0 +1,22 @@
1
+ require 'rails/generators'
2
+ require 'rails/generators/migration'
3
+ require 'active_record'
4
+
5
+ module Que
6
+ class InstallGenerator < Rails::Generators::Base
7
+ include Rails::Generators::Migration
8
+
9
+ namespace "que:install"
10
+ self.source_paths << File.join(File.dirname(__FILE__), 'templates')
11
+ desc "Generates a migration to add Que's job table."
12
+
13
+ def self.next_migration_number(dirname)
14
+ next_migration_number = current_migration_number(dirname) + 1
15
+ ActiveRecord::Migration.next_migration_number(next_migration_number)
16
+ end
17
+
18
+ def create_migration_file
19
+ migration_template 'add_que.rb', 'db/migrate/add_que.rb'
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,9 @@
1
+ class AddQue < ActiveRecord::Migration
2
+ def self.up
3
+ Que.create!
4
+ end
5
+
6
+ def self.down
7
+ Que.drop!
8
+ end
9
+ end
data/lib/que.rb CHANGED
@@ -1,10 +1,60 @@
1
- require 'que/version'
2
-
3
1
  module Que
4
- autoload :Job, 'que/job'
5
- autoload :Worker, 'que/worker'
2
+ autoload :Adapters, 'que/adapters/base'
3
+ autoload :Job, 'que/job'
4
+ autoload :SQL, 'que/sql'
5
+ autoload :Version, 'que/version'
6
+ autoload :Worker, 'que/worker'
6
7
 
7
8
  class << self
8
- attr_accessor :logger
9
+ attr_accessor :logger, :error_handler
10
+ attr_writer :adapter
11
+
12
+ def adapter
13
+ @adapter || raise("Que connection not established!")
14
+ end
15
+
16
+ def connection=(connection)
17
+ self.adapter = if connection.to_s == 'ActiveRecord'
18
+ Adapters::ActiveRecord.new
19
+ else
20
+ case connection.class.to_s
21
+ when 'Sequel::Postgres::Database' then Adapters::Sequel.new(connection)
22
+ when 'ConnectionPool' then Adapters::ConnectionPool.new(connection)
23
+ when 'PG::Connection' then Adapters::PG.new(connection)
24
+ when 'NilClass' then connection
25
+ else raise "Que connection not recognized: #{connection.inspect}"
26
+ end
27
+ end
28
+ end
29
+
30
+ def create!
31
+ execute SQL[:create_table]
32
+ end
33
+
34
+ def drop!
35
+ execute "DROP TABLE que_jobs"
36
+ end
37
+
38
+ def clear!
39
+ execute "DELETE FROM que_jobs"
40
+ end
41
+
42
+ def execute(command, *args)
43
+ case command
44
+ when Symbol then adapter.execute_prepared(command, *args)
45
+ when String then adapter.execute(command, *args)
46
+ end
47
+ end
48
+
49
+ def log(level, text)
50
+ logger.send level, "[Que] #{text}" if logger
51
+ end
52
+
53
+ # Duplicate some Worker config methods to the Que module for convenience.
54
+ [:mode, :mode=, :worker_count=, :sleep_period, :sleep_period=].each do |meth|
55
+ define_method(meth){|*args| Worker.send(meth, *args)}
56
+ end
9
57
  end
10
58
  end
59
+
60
+ require 'que/railtie' if defined? Rails::Railtie
@@ -0,0 +1,9 @@
1
+ module Que
2
+ module Adapters
3
+ class ActiveRecord < Base
4
+ def checkout
5
+ ::ActiveRecord::Base.connection_pool.with_connection { |conn| yield conn.raw_connection }
6
+ end
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,49 @@
1
+ module Que
2
+ module Adapters
3
+ autoload :ActiveRecord, 'que/adapters/active_record'
4
+ autoload :ConnectionPool, 'que/adapters/connection_pool'
5
+ autoload :PG, 'que/adapters/pg'
6
+ autoload :Sequel, 'que/adapters/sequel'
7
+
8
+ class Base
9
+ def initialize(thing = nil)
10
+ @statement_mutex = Mutex.new
11
+ end
12
+
13
+ # The only method that adapters really need to implement. Should lock a
14
+ # PG::Connection (or something that acts like a PG::Connection) so that
15
+ # no other threads are using it and yield it to the block.
16
+ def checkout(&block)
17
+ raise NotImplementedError
18
+ end
19
+
20
+ def execute(*args)
21
+ checkout { |conn| conn.async_exec(*args) }
22
+ end
23
+
24
+ def execute_prepared(name, params = [])
25
+ checkout do |conn|
26
+ unless statements_prepared(conn)[name]
27
+ conn.prepare("que_#{name}", SQL[name])
28
+ statements_prepared(conn)[name] = true
29
+ end
30
+
31
+ conn.exec_prepared("que_#{name}", params)
32
+ end
33
+ end
34
+
35
+ private
36
+
37
+ # Each adapter needs to remember which of its connections have prepared
38
+ # which statements. This is a shared data structure, so protect it. We
39
+ # assume that the hash of statements for a particular connection is only
40
+ # being accessed by the thread that's checked it out, though.
41
+ def statements_prepared(conn)
42
+ @statement_mutex.synchronize do
43
+ @statements_prepared ||= {}
44
+ @statements_prepared[conn] ||= {}
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,14 @@
1
+ module Que
2
+ module Adapters
3
+ class ConnectionPool < Base
4
+ def initialize(pool)
5
+ @pool = pool
6
+ super
7
+ end
8
+
9
+ def checkout(&block)
10
+ @pool.with(&block)
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,17 @@
1
+ require 'monitor'
2
+
3
+ module Que
4
+ module Adapters
5
+ class PG < Base
6
+ def initialize(pg)
7
+ @pg = pg
8
+ @lock = Monitor.new # Must be re-entrant.
9
+ super
10
+ end
11
+
12
+ def checkout
13
+ @lock.synchronize { yield @pg }
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,14 @@
1
+ module Que
2
+ module Adapters
3
+ class Sequel < Base
4
+ def initialize(db)
5
+ @db = db
6
+ super
7
+ end
8
+
9
+ def checkout(&block)
10
+ @db.synchronize(&block)
11
+ end
12
+ end
13
+ end
14
+ end
data/lib/que/job.rb CHANGED
@@ -1,185 +1,164 @@
1
1
  require 'json'
2
2
 
3
3
  module Que
4
- class Job < Sequel::Model
5
- # The Job priority scale:
6
- # 1 = Urgent. Somebody's staring at a spinner waiting on this.
7
- # 2 = ASAP. Should happen within a few minutes of the run_at time.
8
- # 3 = Time-sensitive. Sooner is better than later.
9
- # 4 = Time-insensitive. Shouldn't get delayed forever, though.
10
- # 5 = Whenever. Timing doesn't matter. May be a huge backlog of these.
4
+ class Job
5
+ def initialize(attrs)
6
+ @attrs = attrs
7
+ end
11
8
 
12
- unrestrict_primary_key
9
+ # Subclasses should define their own run methods, but keep an empty one
10
+ # here so we can just do Que::Job.queue in testing.
11
+ def run(*args)
12
+ end
13
13
 
14
- plugin :single_table_inheritance, :type, :key_map => proc(&:to_s),
15
- :model_map => proc{|s| const_get "::#{s}"}
14
+ def _run
15
+ start = Time.now
16
16
 
17
- class Retry < StandardError; end
17
+ run *@attrs[:args]
18
+ destroy unless @destroyed
18
19
 
19
- class << self
20
- # Default is lowest priority, meaning jobs can be done whenever.
21
- def default_priority
22
- @default_priority ||= 5
23
- end
20
+ Que.log :info, "Worked job in #{((Time.now - start) * 1000).round(1)} ms: #{inspect}"
21
+ end
24
22
 
23
+ private
24
+
25
+ def destroy
26
+ Que.execute :destroy_job, [@attrs[:priority], @attrs[:run_at], @attrs[:job_id]]
27
+ @destroyed = true
28
+ end
29
+
30
+ class << self
25
31
  def queue(*args)
26
- create values_for_args *args
32
+ if args.last.is_a?(Hash)
33
+ options = args.pop
34
+ run_at = options.delete(:run_at)
35
+ priority = options.delete(:priority)
36
+ args << options if options.any?
37
+ end
38
+
39
+ attrs = {:job_class => to_s, :args => JSON.dump(args)}
40
+
41
+ if t = run_at || @default_run_at && @default_run_at.call
42
+ attrs[:run_at] = t
43
+ end
44
+
45
+ if p = priority || @default_priority
46
+ attrs[:priority] = p
47
+ end
48
+
49
+ if Que.mode == :sync
50
+ run_job(attrs)
51
+ else
52
+ Que.execute *insert_sql(attrs)
53
+ end
27
54
  end
28
55
 
29
- def work(options = {})
56
+ def work
57
+ # Job.work will typically be called in a loop, where we'd sleep when
58
+ # there's no more work to be done, so its return value should reflect
59
+ # whether we should hit the database again or not. So, return truthy
60
+ # if we worked a job or encountered a typical error while working a
61
+ # job, and falsy if we found nothing to do or hit a connection error.
62
+
30
63
  # Since we're taking session-level advisory locks, we have to hold the
31
64
  # same connection throughout the process of getting a job, working it,
32
65
  # deleting it, and removing the lock.
33
-
34
- DB.synchronize do
66
+ Que.adapter.checkout do
35
67
  begin
36
- return unless job = LOCK.call(:priority => options[:priority] || 5)
37
-
38
- # Edge case: It's possible for the lock statement to have grabbed a
39
- # job that's already been worked, if the statement took its MVCC
40
- # snapshot while the job was processing (making it appear to still
41
- # exist), but didn't actually attempt to lock it until the job was
42
- # finished (making it appear to be unlocked). Now that we have the
43
- # job lock, we know that a previous worker would have deleted it by
44
- # now, so we just make sure it still exists before working it.
45
- this = dataset.where(:priority => job[:priority], :run_at => job[:run_at], :job_id => job[:job_id])
46
- return if this.empty?
47
-
48
- # Split up model instantiation from the DB query, so that model
49
- # instantiation errors can be caught.
50
- model = sti_load(job)
51
-
52
- # Track how long different jobs take to process.
53
- start = Time.now
54
- model.work
55
- time = Time.now - start
56
- Que.logger.info "Worked job in #{(time * 1000).round(1)} ms: #{model.inspect}" if Que.logger
57
-
58
- # Most jobs destroy themselves transactionally in #work. If not,
59
- # take care of them. Jobs that don't destroy themselves run the risk
60
- # of being repeated after a crash.
61
- model.destroy unless model.destroyed?
62
-
63
- # Make sure to return the finished job.
64
- model
65
- rescue Retry
66
- # Don't destroy the job or mark it as having errored. It can be
67
- # retried as soon as it is unlocked.
68
+ if row = Que.execute(:lock_job).first
69
+ # Edge case: It's possible to have grabbed a job that's already
70
+ # been worked, if the SELECT took its MVCC snapshot while the
71
+ # job was processing, but didn't attempt the advisory lock until
72
+ # it was finished. Now that we have the job lock, we know that a
73
+ # previous worker would have deleted it by now, so we just
74
+ # double check that it still exists before working it.
75
+
76
+ # Note that there is currently no spec for this behavior, since
77
+ # I'm not sure how to reliably commit a transaction that deletes
78
+ # the job in a separate thread between this lock and check.
79
+ return true if Que.execute(:check_job, [row['priority'], row['run_at'], row['job_id']]).none?
80
+
81
+ run_job(row)
82
+ else
83
+ Que.log :info, "No jobs available..."
84
+ nil
85
+ end
68
86
  rescue => error
69
- if job && data = JSON.load(job[:data])
70
- count = (data['error_count'] || 0) + 1
87
+ begin
88
+ if row
89
+ # Borrowed the exponential backoff formula and error data format from delayed_job.
90
+ count = row['error_count'].to_i + 1
91
+ run_at = Time.now + (count ** 4 + 3)
92
+ message = "#{error.message}\n#{error.backtrace.join("\n")}"
93
+ Que.execute :set_error, [count, run_at, message, row['priority'], row['run_at'], row['job_id']]
94
+ end
95
+ rescue
96
+ # If we can't reach the DB for some reason, too bad, but don't
97
+ # let it crash the work loop.
98
+ end
71
99
 
72
- this.update :run_at => Time.now + (count ** 4 + 3),
73
- :data => JSON.dump(:error_count => count, :error_message => error.message, :error_backtrace => error.backtrace.join("\n"))
100
+ if Que.error_handler
101
+ Que.error_handler.call(error) rescue nil
74
102
  end
75
103
 
76
- raise
104
+ # If it's a garden variety error, we can just return true, pick up
105
+ # another job, no big deal. If it's a PG::Error, though, assume
106
+ # it's a disconnection or something and that we shouldn't just hit
107
+ # the database again right away.
108
+ return !error.is_a?(PG::Error)
77
109
  ensure
78
- DB.get{pg_advisory_unlock(job[:job_id])} if job
110
+ # Clear the advisory lock we took when locking the job. Important
111
+ # to do this so that they don't pile up in the database.
112
+ Que.execute "SELECT pg_advisory_unlock_all()" if row
79
113
  end
80
114
  end
81
115
  end
82
116
 
83
117
  private
84
118
 
85
- def values_for_args(*args)
86
- opts = args.last.is_a?(Hash) ? args.pop : {}
87
-
88
- result = {}
89
- result[:run_at] = opts.delete(:run_at) if opts[:run_at]
90
- result[:priority] = opts.delete(:priority) if opts[:priority]
91
-
92
- args << opts if opts.any?
93
- result[:args] = JSON.dump(args)
119
+ # Column names are not escaped, so this method should not be called with untrusted hashes.
120
+ def insert_sql(hash)
121
+ number = 0
122
+ columns = []
123
+ placeholders = []
124
+ values = []
125
+
126
+ hash.each do |key, value|
127
+ columns << key
128
+ placeholders << "$#{number += 1}"
129
+ values << value
130
+ end
94
131
 
95
- result
132
+ ["INSERT INTO que_jobs (#{columns.join(', ')}) VALUES (#{placeholders.join(', ')})", values]
96
133
  end
97
- end
98
-
99
- # Send the args attribute to the perform() method.
100
- def work
101
- perform(*JSON.parse(args))
102
- end
103
-
104
- # Call perform on a job to run it. No perform method means NOOP.
105
- def perform(*args)
106
- end
107
134
 
108
- def destroyed?
109
- !!@destroyed
110
- end
111
-
112
- private
135
+ def run_job(attrs)
136
+ attrs = indifferentiate(attrs)
137
+ attrs[:args] = indifferentiate(JSON.load(attrs[:args]))
138
+ const_get("::#{attrs[:job_class]}").new(attrs).tap(&:_run)
139
+ end
113
140
 
114
- # If we add any more callbacks here, make sure to also special-case them in
115
- # queue_array above.
116
- def before_create
117
- self.priority ||= self.class.default_priority
118
-
119
- # If there's no run_at set, the job needs to be run immediately, so we
120
- # need to trigger a worker to work it after it's committed and visible.
121
- if run_at.nil?
122
- case Worker.state
123
- when :sync then DB.after_commit { Job.work }
124
- when :async then DB.after_commit { Worker.wake! }
141
+ def indifferentiate(input)
142
+ case input
143
+ when Hash
144
+ h = indifferent_hash
145
+ input.each { |k, v| h[k] = indifferentiate(v) }
146
+ h
147
+ when Array
148
+ input.map { |v| indifferentiate(v) }
149
+ else
150
+ input
125
151
  end
126
152
  end
127
153
 
128
- super
129
- end
130
-
131
- def after_destroy
132
- super
133
- @destroyed = true
154
+ def indifferent_hash
155
+ # Tiny hack to better support Rails.
156
+ if {}.respond_to?(:with_indifferent_access)
157
+ {}.with_indifferent_access
158
+ else
159
+ Hash.new { |hash, key| hash[key.to_s] if Symbol === key }
160
+ end
161
+ end
134
162
  end
135
-
136
- sql = <<-SQL
137
- WITH RECURSIVE cte AS (
138
- SELECT (job).*, pg_try_advisory_lock((job).job_id) AS locked
139
- FROM (
140
- SELECT job
141
- FROM jobs AS job
142
- WHERE ((run_at <= now()) AND (priority <= ?))
143
- ORDER BY priority, run_at, job_id
144
- LIMIT 1
145
- ) AS t1
146
- UNION ALL (
147
- SELECT (job).*, pg_try_advisory_lock((job).job_id) AS locked
148
- FROM (
149
- SELECT (
150
- SELECT job
151
- FROM jobs AS job
152
- WHERE ((run_at <= now()) AND (priority <= ?) AND ((priority, run_at, job_id) > (cte.priority, cte.run_at, cte.job_id)))
153
- ORDER BY priority, run_at, job_id
154
- LIMIT 1
155
- ) AS job
156
- FROM cte
157
- WHERE NOT cte.locked
158
- LIMIT 1
159
- ) AS t1)
160
- )
161
- SELECT *
162
- FROM cte
163
- WHERE locked
164
- SQL
165
-
166
- LOCK = DB[sql, :$priority, :$priority].prepare(:first, :lock_job)
167
-
168
- # An alternate scheme using LATERAL, which will arrive in Postgres 9.3.
169
- # Basically the same, but benchmark to see if it's faster/just as reliable.
170
-
171
- # with recursive
172
- # t as (select *, pg_try_advisory_lock(s.job_id) as locked
173
- # from (select * from jobs j
174
- # where run_at >= now()
175
- # order by priority, run_at, job_id limit 1) s
176
- # union all
177
- # select j.*, pg_try_advisory_lock(j.job_id)
178
- # from (select * from t where not locked) t,
179
- # lateral (select * from jobs
180
- # where run_at >= now()
181
- # and (priority,run_at,job_id) > (t.priority,t.run_at,t.job_id)
182
- # order by priority, run_at, job_id limit 1) j
183
- # select * from t where locked;
184
163
  end
185
164
  end