que 0.0.1 → 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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