que 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: df82eb95360d4f2dd2d6ae5185a209836946a5a3
4
+ data.tar.gz: edbd98c7b468295a1d67ff135c076def834a1f0f
5
+ SHA512:
6
+ metadata.gz: d93d47004b33e2f1e7e82e05351b629238d36eb9e48b8a142f421d267217f99ba51c6916267e9fb2c636a0d0ddfc5ab680c8b1168b6f089ace6cb396bb2989a1
7
+ data.tar.gz: c697670fcd1525986e22fa743cbae99b7e0107143c7e2b7e5b26ab52a4bdc4ec8f1d0fcb295cc3dab1861499af42186480058fde8e5ebc1827ad6a7ddf0a515c
@@ -0,0 +1,17 @@
1
+ *.gem
2
+ *.rbc
3
+ .bundle
4
+ .config
5
+ .yardoc
6
+ Gemfile.lock
7
+ InstalledFiles
8
+ _yardoc
9
+ coverage
10
+ doc/
11
+ lib/bundler/man
12
+ pkg
13
+ rdoc
14
+ spec/reports
15
+ test/tmp
16
+ test/version_tmp
17
+ tmp
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --color
2
+ --profile
3
+ --order random
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in que.gemspec
4
+ gemspec
@@ -0,0 +1,22 @@
1
+ Copyright (c) 2013 Chris Hanks
2
+
3
+ MIT License
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining
6
+ a copy of this software and associated documentation files (the
7
+ "Software"), to deal in the Software without restriction, including
8
+ without limitation the rights to use, copy, modify, merge, publish,
9
+ distribute, sublicense, and/or sell copies of the Software, and to
10
+ permit persons to whom the Software is furnished to do so, subject to
11
+ the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be
14
+ included in all copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
20
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
21
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
22
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,102 @@
1
+ # Que
2
+
3
+ A job queue that uses Postgres' advisory lock system. The aim is for job queuing to be efficient and highly concurrent (there's no need for a locked_at column or SELECT FOR UPDATE NOWAIT queries) but still very durable (with all the stability that Postgres offers).
4
+
5
+ It was extracted from an app of mine where it worked well for me, but I wouldn't recommend anyone use it until it's generalized some more. It currently requires the use of Sequel as an ORM, but I expect that it could be expanded to support ActiveRecord.
6
+
7
+ Features:
8
+ * Jobs can be queued transactionally, alongside every other change to your database.
9
+ * If a worker process crashes or segfaults, the jobs it was working are immediately released to be picked up by other workers.
10
+ * Workers are multi-threaded, similar to Sidekiq, so the same process can work many jobs at the same time.
11
+ * Workers can run in your web processes. This means if you're on Heroku, there's no need to have a worker process constantly running.
12
+
13
+ ## Installation
14
+
15
+ Add this line to your application's Gemfile:
16
+
17
+ gem 'que'
18
+
19
+ And then execute:
20
+
21
+ $ bundle
22
+
23
+ Or install it yourself as:
24
+
25
+ $ gem install que
26
+
27
+ ## Usage
28
+
29
+ Create a jobs table that looks something like:
30
+
31
+ CREATE TABLE jobs
32
+ (
33
+ priority integer NOT NULL,
34
+ run_at timestamp with time zone NOT NULL DEFAULT now(),
35
+ job_id bigserial NOT NULL,
36
+ created_at timestamp with time zone NOT NULL DEFAULT now(),
37
+ type text NOT NULL,
38
+ args json NOT NULL DEFAULT '[]'::json,
39
+ data json NOT NULL DEFAULT '{}'::json,
40
+ CONSTRAINT jobs_pkey PRIMARY KEY (priority, run_at, job_id),
41
+ CONSTRAINT valid_priority CHECK (priority >= 1 AND priority <= 5)
42
+ );
43
+
44
+ Start up the worker to process jobs:
45
+
46
+ # In an initializer:
47
+ Que::Worker.state = :async
48
+
49
+ # Alternatively, you can set state to :sync to process jobs in the same
50
+ # thread as they're queued, which is useful for testing, or set it to :off
51
+ # to simply leave them in the jobs table.
52
+
53
+ Create a class for each type of job you want to run:
54
+
55
+ class ChargeCreditCard < Que::Job
56
+ @default_priority = 1 # Highest priority.
57
+
58
+ def perform(user_id, card_id)
59
+ # Do stuff.
60
+
61
+ db.transaction do
62
+ # Write any changes you'd like to the database.
63
+
64
+ # It's best to destroy the job in the same transaction as any other
65
+ # changes you make. Que will destroy the job for you after the
66
+ # perform method if you don't do it here, but if your job writes to
67
+ # the DB but doesn't destroy the job in the same transaction, it's
68
+ # possible that the job could be repeated in the event of a crash.
69
+ destroy
70
+ end
71
+ end
72
+ end
73
+
74
+ Queue that type of job. Again, it's best to take advantage of Postgres by doing this in a transaction with other changes you're making:
75
+
76
+ DB.transaction do
77
+ # Persist credit card information
78
+ card = CreditCard.create(params[:credit_card])
79
+ ChargeCreditCard.queue(current_user.id, card.id)
80
+ end
81
+
82
+ ## TODO
83
+
84
+ These aren't promises, just ideas for stuff that could be done in the future.
85
+
86
+ * Railtie, to make default setup easy.
87
+ * ActiveRecord support.
88
+ * Keep deleted jobs.
89
+ * Use LISTEN/NOTIFY for checking for new jobs, rather than signaling the worker within the same process.
90
+ * Multiple queues (in multiple tables?)
91
+ * More configurable logging that isn't tied to Rails.
92
+ * Integration with ActionMailer for easier mailings.
93
+ * Options for max_run_time and max_attempts that are specific to job classes.
94
+ * Rake tasks for creating/working/dropping/clearing queues.
95
+
96
+ ## Contributing
97
+
98
+ 1. Fork it
99
+ 2. Create your feature branch (`git checkout -b my-new-feature`)
100
+ 3. Commit your changes (`git commit -am 'Add some feature'`)
101
+ 4. Push to the branch (`git push origin my-new-feature`)
102
+ 5. Create new Pull Request
@@ -0,0 +1 @@
1
+ require 'bundler/gem_tasks'
@@ -0,0 +1,10 @@
1
+ require 'que/version'
2
+
3
+ module Que
4
+ autoload :Job, 'que/job'
5
+ autoload :Worker, 'que/worker'
6
+
7
+ class << self
8
+ attr_accessor :logger
9
+ end
10
+ end
@@ -0,0 +1,185 @@
1
+ require 'json'
2
+
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.
11
+
12
+ unrestrict_primary_key
13
+
14
+ plugin :single_table_inheritance, :type, :key_map => proc(&:to_s),
15
+ :model_map => proc{|s| const_get "::#{s}"}
16
+
17
+ class Retry < StandardError; end
18
+
19
+ class << self
20
+ # Default is lowest priority, meaning jobs can be done whenever.
21
+ def default_priority
22
+ @default_priority ||= 5
23
+ end
24
+
25
+ def queue(*args)
26
+ create values_for_args *args
27
+ end
28
+
29
+ def work(options = {})
30
+ # Since we're taking session-level advisory locks, we have to hold the
31
+ # same connection throughout the process of getting a job, working it,
32
+ # deleting it, and removing the lock.
33
+
34
+ DB.synchronize do
35
+ 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
+ rescue => error
69
+ if job && data = JSON.load(job[:data])
70
+ count = (data['error_count'] || 0) + 1
71
+
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"))
74
+ end
75
+
76
+ raise
77
+ ensure
78
+ DB.get{pg_advisory_unlock(job[:job_id])} if job
79
+ end
80
+ end
81
+ end
82
+
83
+ private
84
+
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)
94
+
95
+ result
96
+ 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
+
108
+ def destroyed?
109
+ !!@destroyed
110
+ end
111
+
112
+ private
113
+
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! }
125
+ end
126
+ end
127
+
128
+ super
129
+ end
130
+
131
+ def after_destroy
132
+ super
133
+ @destroyed = true
134
+ 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
+ end
185
+ end
@@ -0,0 +1,3 @@
1
+ module Que
2
+ VERSION = '0.0.1'
3
+ end
@@ -0,0 +1,216 @@
1
+ module Que
2
+ # There are multiple workers running at a given time, each with a given
3
+ # minimum priority that they care about for jobs. A worker will continuously
4
+ # look for jobs and work them. If there's no job available, the worker will go
5
+ # to sleep until it is awakened by an external thread.
6
+
7
+ # Use Worker.state = ... to set the current state. There are three states:
8
+ # :async => Work jobs in dedicated threads. Used in production.
9
+ # :sync => Work jobs immediately, as they're queued, in the current thread. Used in testing.
10
+ # :off => Don't work jobs at all. Must use Job#work or Job.work explicitly.
11
+
12
+ # Worker.wake! will wake up the sleeping worker with the lowest minimum job
13
+ # priority. Worker.wake! may be run by another thread handling a web request,
14
+ # or by the wrangler thread (which wakes a worker every five seconds, to
15
+ # handle scheduled jobs). It only has an effect when running in async mode.
16
+
17
+ class Worker
18
+ # Each worker has a corresponding thread, which contains two variables:
19
+ # :directive, to define what it should be doing, and :state, to define what
20
+ # it's actually doing at the moment. Need to be careful that these variables
21
+ # are only modified by a single thread at a time (hence, MonitorMixin).
22
+ include MonitorMixin
23
+
24
+ # The Worker class itself needs to be protected as well, to make sure that
25
+ # multiple threads aren't stopping/starting it at the same time.
26
+ extend MonitorMixin
27
+
28
+ # Default worker priorities. Rule of thumb: number of lowest-priority
29
+ # workers should equal number of processors available to us.
30
+ PRIORITIES = [5, 5, 5, 5, 4, 3, 2, 1].freeze
31
+
32
+ # Which errors should signal a worker that it should hold off before trying
33
+ # to grab another job, in order to avoid spamming the logs.
34
+ DELAYABLE_ERRORS = %w(
35
+ Sequel::DatabaseConnectionError
36
+ Sequel::DatabaseDisconnectError
37
+ )
38
+
39
+ # How long the wrangler thread should wait between pings of the database.
40
+ # Future directions: when we have multiple dynos, add rand() to this value
41
+ # in the wrangler loop below, so that the dynos' checks will be spaced out.
42
+ SLEEP_PERIOD = 5
43
+
44
+ # How long to sleep, in repeated increments, for something to happen.
45
+ WAIT_PERIOD = 0.0001 # 0.1 ms
46
+
47
+ # How long a worker should wait before trying to get another job, in the
48
+ # event of a database connection problem.
49
+ ERROR_PERIOD = 5
50
+
51
+ attr_reader :thread, :priority
52
+
53
+ def initialize(priority)
54
+ super() # For MonitorMixin
55
+
56
+ # These threads have a bad habit of never even having their directive and
57
+ # state set if we do it inside their threads. So instead, force the issue
58
+ # by doing it outside and holding them up via a queue until their initial
59
+ # state is set.
60
+ q = Queue.new
61
+
62
+ @priority = priority
63
+ @thread = Thread.new do
64
+ q.pop
65
+ job = nil
66
+
67
+ loop do
68
+ sleep! unless work_job
69
+
70
+ if @thread[:directive] == :sleep
71
+ @thread[:state] = :sleeping
72
+ sleep
73
+ end
74
+ end
75
+ end
76
+
77
+ # All workers are working when first instantiated.
78
+ synchronize { @thread[:directive], @thread[:state] = :work, :working }
79
+
80
+ # Now the worker can start.
81
+ q.push nil
82
+
83
+ # Default thread priority is 0 - make worker threads a bit less important
84
+ # than threads that are handling requests.
85
+ @thread.priority = -1
86
+ end
87
+
88
+ # If the worker is asleep, wakes it up and returns truthy. If it's already
89
+ # awake, does nothing and returns falsy.
90
+ def wake!
91
+ synchronize do
92
+ if sleeping?
93
+ # There's a very brief period of time where the worker may be marked
94
+ # as sleeping but the thread hasn't actually gone to sleep yet.
95
+ wait until @thread.stop?
96
+ @thread[:directive] = :work
97
+
98
+ # Have to set state here so that another poke immediately after this
99
+ # one doesn't see the current state as sleeping.
100
+ @thread[:state] = :working
101
+
102
+ # Now it's safe to wake up the worker.
103
+ @thread.wakeup
104
+ end
105
+ end
106
+ end
107
+
108
+ def sleep!
109
+ synchronize { @thread[:directive] = :sleep }
110
+ end
111
+
112
+ def awake?
113
+ synchronize do
114
+ %w(sleeping working).include?(@thread[:state].to_s) &&
115
+ %w(sleep work).include?(@thread[:directive].to_s) &&
116
+ %w(sleep run).include?(@thread.status)
117
+ end
118
+ end
119
+
120
+ def wait_for_sleep
121
+ wait until synchronize { sleeping? }
122
+ end
123
+
124
+ private
125
+
126
+ def work_job
127
+ Job.work(:priority => priority)
128
+ rescue => error
129
+ self.class.notify_error "Worker error!", error
130
+ sleep ERROR_PERIOD if DELAYABLE_ERRORS.include?(error.class.to_s)
131
+ return true # There's work available.
132
+ end
133
+
134
+ def sleeping?
135
+ @thread[:state] == :sleeping
136
+ end
137
+
138
+ def wait
139
+ sleep WAIT_PERIOD
140
+ end
141
+
142
+ # The Worker class is responsible for managing the worker instances.
143
+ class << self
144
+ def state=(state)
145
+ synchronize do
146
+ Que.logger.info "Setting Worker to #{state}..." if Que.logger
147
+ case state
148
+ when :async
149
+ # If this is the first time starting up Worker, start up all workers
150
+ # immediately, for the case of a restart during heavy app usage.
151
+ workers
152
+ # Make sure the wrangler thread is running, it'll do the rest.
153
+ @wrangler ||= Thread.new { loop { wrangle } }
154
+ when :sync, :off
155
+ # Put all the workers to sleep.
156
+ workers.each(&:sleep!).each(&:wait_for_sleep)
157
+ else
158
+ raise "Bad Worker state! #{state.inspect}"
159
+ end
160
+
161
+ Que.logger.info "Set Worker to #{state}" if Que.logger
162
+ @state = state
163
+ end
164
+ end
165
+
166
+ def state
167
+ synchronize { @state ||= :off }
168
+ end
169
+
170
+ def async?
171
+ state == :async
172
+ end
173
+
174
+ # All workers are up and processing jobs?
175
+ def up?(*states)
176
+ synchronize { async? && workers.map(&:priority) == PRIORITIES && workers.all?(&:awake?) }
177
+ end
178
+
179
+ def workers
180
+ @workers || synchronize { @workers ||= PRIORITIES.map { |i| new(i) } }
181
+ end
182
+
183
+ # Wake up just one worker to work a job, if running async.
184
+ def wake!
185
+ synchronize { async? && workers.find(&:wake!) }
186
+ end
187
+
188
+ def notify_error(message, error)
189
+ log_error message, error
190
+ #ExceptionNotifier.notify_exception(error)
191
+ rescue => error
192
+ log_error "Error notification error!", error
193
+ end
194
+
195
+ private
196
+
197
+ # The wrangler runs this method continuously.
198
+ def wrangle
199
+ sleep SLEEP_PERIOD
200
+ wake!
201
+ rescue => error
202
+ notify_error "Wrangler Error!", error
203
+ end
204
+
205
+ def log_error(message, error)
206
+ if Que.logger
207
+ Que.logger.error <<-ERROR
208
+ #{message}
209
+ #{error.message}
210
+ #{error.backtrace.join("\n")}
211
+ ERROR
212
+ end
213
+ end
214
+ end
215
+ end
216
+ end
@@ -0,0 +1,28 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'que/version'
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = 'que'
8
+ spec.version = Que::VERSION
9
+ spec.authors = ["Chris Hanks"]
10
+ spec.email = ['christopher.m.hanks@gmail.com']
11
+ spec.description = %q{Durable job queueing with PostgreSQL.}
12
+ spec.summary = %q{Durable, efficient job queueing with PostgreSQL.}
13
+ spec.homepage = ''
14
+ spec.license = 'MIT'
15
+
16
+ spec.files = `git ls-files`.split($/)
17
+ spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
18
+ spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
19
+ spec.require_paths = ['lib']
20
+
21
+ spec.add_development_dependency 'bundler', '~> 1.3'
22
+ spec.add_development_dependency 'rake'
23
+ spec.add_development_dependency 'rspec', '~> 2.14.1'
24
+ spec.add_development_dependency 'pry'
25
+
26
+ spec.add_dependency 'sequel'
27
+ spec.add_dependency 'pg'
28
+ end
@@ -0,0 +1,34 @@
1
+ require 'sequel'
2
+ require 'que'
3
+
4
+ DB = Sequel.connect "postgres://postgres:@localhost/que"
5
+
6
+ DB.drop_table? :jobs
7
+ DB.run <<-SQL
8
+ CREATE TABLE jobs
9
+ (
10
+ priority integer NOT NULL,
11
+ run_at timestamp with time zone NOT NULL DEFAULT now(),
12
+ job_id bigserial NOT NULL,
13
+ created_at timestamp with time zone NOT NULL DEFAULT now(),
14
+ type text NOT NULL,
15
+ args json NOT NULL DEFAULT '[]'::json,
16
+ data json NOT NULL DEFAULT '{}'::json,
17
+ CONSTRAINT jobs_pkey PRIMARY KEY (priority, run_at, job_id),
18
+ CONSTRAINT valid_priority CHECK (priority >= 1 AND priority <= 5)
19
+ );
20
+ SQL
21
+
22
+ RSpec.configure do |config|
23
+ config.before do
24
+ Que::Worker.state = :off
25
+ DB[:jobs].delete
26
+ end
27
+ end
28
+
29
+ Que::Worker.state = :async # Boot up.
30
+
31
+ # For use when debugging specs:
32
+ # require 'logger'
33
+ # Que.logger = Logger.new(STDOUT)
34
+ # DB.loggers << Logger.new(STDOUT)
@@ -0,0 +1,45 @@
1
+ require 'spec_helper'
2
+
3
+ describe "Que::Job error handling" do
4
+ class ErrorJob < Que::Job
5
+ def perform(*args)
6
+ raise "Boo!"
7
+ end
8
+ end
9
+
10
+ it "should increment the error_count, persist the error message and reschedule the job" do
11
+ ErrorJob.queue
12
+
13
+ proc { Que::Job.work }.should raise_error RuntimeError, "Boo!"
14
+ Que::Job.count.should be 1
15
+
16
+ job = Que::Job.first
17
+ data = JSON.load(job.data)
18
+ data['error_count'].should be 1
19
+ data['error_message'].should =~ /Boo!/
20
+ job.run_at.should be_within(1).of Time.now + 4
21
+ end
22
+
23
+ it "should reschedule jobs with exponentially increasing times" do
24
+ ErrorJob.queue
25
+ Que::Job.dataset.update :data => JSON.dump(:error_count => 5)
26
+
27
+ proc { Que::Job.work }.should raise_error RuntimeError, "Boo!"
28
+ Que::Job.count.should be 1
29
+
30
+ job = Que::Job.first
31
+ data = JSON.load(job.data)
32
+ data['error_count'].should be 6
33
+ data['error_message'].should =~ /Boo!/
34
+ job.run_at.should be_within(1).of Time.now + 1299
35
+ end
36
+
37
+ it "should handle errors from jobs that cannot be deserialized" do
38
+ DB[:jobs].insert :type => 'NonexistentJob', :priority => 1
39
+ proc { Que::Job.work }.should raise_error NameError, /uninitialized constant NonexistentJob/
40
+
41
+ job = DB[:jobs].first
42
+ JSON.load(job[:data])['error_count'].should be 1
43
+ job[:run_at].should be_within(1).of Time.now + 4
44
+ end
45
+ end
@@ -0,0 +1,67 @@
1
+ require 'spec_helper'
2
+
3
+ describe "Que::Job.queue" do
4
+ it "should create a job in the DB" do
5
+ Que::Job.queue :param1 => 4, :param2 => 'ferret', :param3 => false
6
+ Que::Job.count.should == 1
7
+
8
+ job = Que::Job.first
9
+ job.type.should == 'Que::Job'
10
+ job.run_at.should be_within(1).of Time.now
11
+ job.priority.should be 5 # Defaults to lowest priority.
12
+ JSON.load(job.args).should == [{'param1' => 4, 'param2' => 'ferret', 'param3' => false}]
13
+ end
14
+
15
+ it "should accept a :run_at argument" do
16
+ time = Time.at(Time.now.to_i)
17
+ Que::Job.queue :user_id => 4, :test_number => 8, :run_at => time
18
+
19
+ Que::Job.count.should == 1
20
+ job = Que::Job.first
21
+ job.type.should == 'Que::Job'
22
+ job.run_at.should == time
23
+ job.priority.should == 5
24
+ JSON.load(job.args).should == [{'user_id' => 4, 'test_number' => 8}]
25
+ end
26
+
27
+ it "should accept a :priority argument" do
28
+ Que::Job.queue :user_id => 4, :test_number => 8, :priority => 1
29
+
30
+ Que::Job.count.should == 1
31
+ job = Que::Job.first
32
+ job.type.should == 'Que::Job'
33
+ job.run_at.should be_within(1).of Time.now
34
+ job.priority.should be 1
35
+ JSON.load(job.args).should == [{'user_id' => 4, 'test_number' => 8}]
36
+ end
37
+
38
+ it "should respect a default_priority for the class" do
39
+ class TestPriorityJob < Que::Job
40
+ @default_priority = 2
41
+ end
42
+
43
+ TestPriorityJob.queue :user_id => 4, :test_number => 8
44
+
45
+ Que::Job.count.should == 1
46
+ job = Que::Job.first
47
+ job.type.should == 'TestPriorityJob'
48
+ job.run_at.should be_within(1).of Time.now
49
+ job.priority.should be 2
50
+ JSON.load(job.args).should == [{'user_id' => 4, 'test_number' => 8}]
51
+ end
52
+
53
+ it "should let a :priority option override a default_priority for the class" do
54
+ class OtherTestPriorityJob < Que::Job
55
+ @default_priority = 2
56
+ end
57
+
58
+ OtherTestPriorityJob.queue :user_id => 4, :test_number => 8, :priority => 4
59
+
60
+ Que::Job.count.should == 1
61
+ job = Que::Job.first
62
+ job.type.should == 'OtherTestPriorityJob'
63
+ job.run_at.should be_within(1).of Time.now
64
+ job.priority.should be 4
65
+ JSON.load(job.args).should == [{'user_id' => 4, 'test_number' => 8}]
66
+ end
67
+ end
@@ -0,0 +1,168 @@
1
+ require 'spec_helper'
2
+
3
+ describe "Que::Job.work" do
4
+ it "should automatically delete jobs from the database's queue" do
5
+ Que::Job.count.should be 0
6
+ Que::Job.queue
7
+ Que::Job.count.should be 1
8
+ Que::Job.work
9
+ Que::Job.count.should be 0
10
+ end
11
+
12
+ it "should pass a job's arguments to its perform method" do
13
+ class JobWorkTest < Que::Job
14
+ def perform(*args)
15
+ $passed_args = args
16
+ end
17
+ end
18
+
19
+ JobWorkTest.queue 5, 'ferret', :lazy => true
20
+
21
+ Que::Job.work
22
+ $passed_args.should == [5, 'ferret', {'lazy' => true}]
23
+ end
24
+
25
+ it "should prefer a job with higher priority" do
26
+ Que::Job.queue
27
+ Que::Job.queue :priority => 1
28
+
29
+ Que::Job.select_order_map(:priority).should == [1, 5]
30
+ Que::Job.work
31
+ Que::Job.select_order_map(:priority).should == [5]
32
+ end
33
+
34
+ it "should prefer a job that was scheduled to run longer ago" do
35
+ now = Time.at(Time.now.to_i) # Prevent rounding errors by rounding to the nearest second.
36
+ recently = now - 60
37
+ long_ago = now - 61
38
+
39
+ Que::Job.queue :run_at => recently
40
+ Que::Job.queue :run_at => long_ago
41
+
42
+ Que::Job.select_order_map(:run_at).should == [long_ago, recently]
43
+ Que::Job.work
44
+ Que::Job.select_order_map(:run_at).should == [recently]
45
+ end
46
+
47
+ it "should only work a job whose run_at has already passed" do
48
+ now = Time.at(Time.now.to_i) # Prevent rounding errors by rounding to the nearest second.
49
+ past = now - 60
50
+ soon = now + 60
51
+
52
+ Que::Job.queue :run_at => past
53
+ Que::Job.queue :run_at => soon
54
+
55
+ Que::Job.select_order_map(:run_at).should == [past, soon]
56
+ Que::Job.work
57
+ Que::Job.select_order_map(:run_at).should == [soon]
58
+ Que::Job.work
59
+ Que::Job.select_order_map(:run_at).should == [soon]
60
+ end
61
+
62
+ it "should prefer a job that was scheduled earlier, and therefore has a lower job_id" do
63
+ time = Time.now - 60
64
+ Que::Job.queue :run_at => time
65
+ Que::Job.queue :run_at => time
66
+
67
+ a, b = Que::Job.select_order_map(:job_id)
68
+ Que::Job.work
69
+ Que::Job.select_order_map(:job_id).should == [b]
70
+ end
71
+
72
+ it "should lock the job it selects" do
73
+ $q1, $q2 = Queue.new, Queue.new
74
+
75
+ class LockJob < Que::Job
76
+ def perform(*args)
77
+ $q1.push nil
78
+ $q2.pop
79
+ end
80
+ end
81
+
82
+ job = LockJob.queue
83
+ @thread = Thread.new { Que::Job.work }
84
+
85
+ # Wait until job is being worked.
86
+ $q1.pop
87
+
88
+ # Job should be advisory-locked...
89
+ DB.select{pg_try_advisory_lock(job.job_id)}.single_value.should be false
90
+
91
+ # ...and Job.work should ignore advisory-locked jobs.
92
+ Que::Job.work.should be nil
93
+
94
+ # Let LockJob finish.
95
+ $q2.push nil
96
+
97
+ # Make sure there aren't any errors.
98
+ @thread.join
99
+ end
100
+
101
+ it "that raises a Que::Job::Retry should abort the job, leaving it to be retried" do
102
+ class RetryJob < Que::Job
103
+ def perform(*args)
104
+ raise Que::Job::Retry
105
+ end
106
+ end
107
+
108
+ job = RetryJob.queue
109
+ Que::Job.count.should be 1
110
+ Que::Job.work
111
+ Que::Job.count.should be 1
112
+
113
+ same_job = Que::Job.first
114
+ same_job.job_id.should == job.job_id
115
+ same_job.run_at.should == job.run_at
116
+ same_job.data['error_count'].should be nil
117
+ end
118
+
119
+ it "should handle subclasses of other jobs" do
120
+ $class_job_array = []
121
+
122
+ class ClassJob < Que::Job
123
+ def perform(*args)
124
+ $class_job_array << 2
125
+ end
126
+ end
127
+
128
+ class SubclassJob < ClassJob
129
+ def perform(*args)
130
+ $class_job_array << 1
131
+ super
132
+ end
133
+ end
134
+
135
+ SubclassJob.queue
136
+ Que::Job.get(:type).should == 'SubclassJob'
137
+ Que::Job.work
138
+ $class_job_array.should == [1, 2]
139
+ end
140
+
141
+ describe "should support a logger" do
142
+ before do
143
+ @logger = Object.new
144
+ def @logger.method_missing(m, message)
145
+ @messages ||= []
146
+ @messages << message
147
+ end
148
+ Que.logger = @logger
149
+ end
150
+
151
+ after do
152
+ Que.logger = nil
153
+ end
154
+
155
+ def messages
156
+ @logger.instance_variable_get(:@messages)
157
+ end
158
+
159
+ it "should write messages to the logger" do
160
+ Que::Job.queue
161
+ Que::Job.work
162
+
163
+ messages.should be_an_instance_of Array
164
+ messages.length.should == 1
165
+ messages[0].should =~ /\AWorked job in/
166
+ end
167
+ end
168
+ end
@@ -0,0 +1,31 @@
1
+ require 'spec_helper'
2
+
3
+ describe Que::Worker do
4
+ class ExceptionJob < Que::Job
5
+ def perform(*args)
6
+ raise "Blah!"
7
+ end
8
+ end
9
+
10
+ it "should not be taken out by an error, and keep looking for jobs" do
11
+ ExceptionJob.queue
12
+ Que::Job.queue
13
+
14
+ Que::Worker.state = :async
15
+ Que::Worker.wake!
16
+ @worker = Que::Worker.workers.first
17
+
18
+ {} until @worker.thread[:state] == :sleeping
19
+
20
+ # Job was worked, ExceptionJob remains.
21
+ Que::Job.select_map(:type).should == ['ExceptionJob']
22
+ end
23
+
24
+ it "#async? and #up? should return whether Worker is async and whether there are workers running, respectively" do
25
+ Que::Worker.should_not be_async
26
+ Que::Worker.should_not be_up
27
+ Que::Worker.state = :async
28
+ Que::Worker.should be_async
29
+ Que::Worker.should be_up
30
+ end
31
+ end
metadata ADDED
@@ -0,0 +1,149 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: que
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ platform: ruby
6
+ authors:
7
+ - Chris Hanks
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2013-11-07 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: bundler
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ~>
18
+ - !ruby/object:Gem::Version
19
+ version: '1.3'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ~>
25
+ - !ruby/object:Gem::Version
26
+ version: '1.3'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rake
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - '>='
32
+ - !ruby/object:Gem::Version
33
+ version: '0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - '>='
39
+ - !ruby/object:Gem::Version
40
+ version: '0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rspec
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ~>
46
+ - !ruby/object:Gem::Version
47
+ version: 2.14.1
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ~>
53
+ - !ruby/object:Gem::Version
54
+ version: 2.14.1
55
+ - !ruby/object:Gem::Dependency
56
+ name: pry
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - '>='
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - '>='
67
+ - !ruby/object:Gem::Version
68
+ version: '0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: sequel
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - '>='
74
+ - !ruby/object:Gem::Version
75
+ version: '0'
76
+ type: :runtime
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - '>='
81
+ - !ruby/object:Gem::Version
82
+ version: '0'
83
+ - !ruby/object:Gem::Dependency
84
+ name: pg
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - '>='
88
+ - !ruby/object:Gem::Version
89
+ version: '0'
90
+ type: :runtime
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - '>='
95
+ - !ruby/object:Gem::Version
96
+ version: '0'
97
+ description: Durable job queueing with PostgreSQL.
98
+ email:
99
+ - christopher.m.hanks@gmail.com
100
+ executables: []
101
+ extensions: []
102
+ extra_rdoc_files: []
103
+ files:
104
+ - .gitignore
105
+ - .rspec
106
+ - Gemfile
107
+ - LICENSE.txt
108
+ - README.md
109
+ - Rakefile
110
+ - lib/que.rb
111
+ - lib/que/job.rb
112
+ - lib/que/version.rb
113
+ - lib/que/worker.rb
114
+ - que.gemspec
115
+ - spec/spec_helper.rb
116
+ - spec/unit/error_spec.rb
117
+ - spec/unit/queue_spec.rb
118
+ - spec/unit/work_spec.rb
119
+ - spec/unit/worker_spec.rb
120
+ homepage: ''
121
+ licenses:
122
+ - MIT
123
+ metadata: {}
124
+ post_install_message:
125
+ rdoc_options: []
126
+ require_paths:
127
+ - lib
128
+ required_ruby_version: !ruby/object:Gem::Requirement
129
+ requirements:
130
+ - - '>='
131
+ - !ruby/object:Gem::Version
132
+ version: '0'
133
+ required_rubygems_version: !ruby/object:Gem::Requirement
134
+ requirements:
135
+ - - '>='
136
+ - !ruby/object:Gem::Version
137
+ version: '0'
138
+ requirements: []
139
+ rubyforge_project:
140
+ rubygems_version: 2.1.9
141
+ signing_key:
142
+ specification_version: 4
143
+ summary: Durable, efficient job queueing with PostgreSQL.
144
+ test_files:
145
+ - spec/spec_helper.rb
146
+ - spec/unit/error_spec.rb
147
+ - spec/unit/queue_spec.rb
148
+ - spec/unit/work_spec.rb
149
+ - spec/unit/worker_spec.rb