que 0.0.1

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,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