queue_classic 2.0.5 → 2.1.0
Sign up to get free protection for your applications and to get access to all the features.
- data/lib/queue_classic/conn.rb +29 -25
- data/lib/queue_classic/queries.rb +1 -1
- data/lib/queue_classic/queue.rb +0 -1
- data/lib/queue_classic/tasks.rb +4 -1
- data/lib/queue_classic/worker.rb +67 -88
- data/readme.md +106 -470
- data/test/worker_test.rb +8 -8
- metadata +1 -1
data/lib/queue_classic/conn.rb
CHANGED
@@ -1,46 +1,51 @@
|
|
1
|
+
require 'thread'
|
2
|
+
|
1
3
|
module QC
|
2
4
|
module Conn
|
3
5
|
extend self
|
6
|
+
@exec_mutex = Mutex.new
|
4
7
|
|
5
8
|
def execute(stmt, *params)
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
9
|
+
@exec_mutex.synchronize do
|
10
|
+
log(:at => "exec_sql", :sql => stmt.inspect)
|
11
|
+
begin
|
12
|
+
params = nil if params.empty?
|
13
|
+
r = connection.exec(stmt, params)
|
14
|
+
result = []
|
15
|
+
r.each {|t| result << t}
|
16
|
+
result.length > 1 ? result : result.pop
|
17
|
+
rescue PGError => e
|
18
|
+
log(:error => e.inspect)
|
19
|
+
disconnect
|
20
|
+
raise
|
21
|
+
end
|
17
22
|
end
|
18
23
|
end
|
19
24
|
|
20
25
|
def notify(chan)
|
21
|
-
log(:
|
26
|
+
log(:at => "NOTIFY")
|
22
27
|
execute('NOTIFY "' + chan + '"') #quotes matter
|
23
28
|
end
|
24
29
|
|
25
30
|
def listen(chan)
|
26
|
-
log(:
|
31
|
+
log(:at => "LISTEN")
|
27
32
|
execute('LISTEN "' + chan + '"') #quotes matter
|
28
33
|
end
|
29
34
|
|
30
35
|
def unlisten(chan)
|
31
|
-
log(:
|
36
|
+
log(:at => "UNLISTEN")
|
32
37
|
execute('UNLISTEN "' + chan + '"') #quotes matter
|
33
38
|
end
|
34
39
|
|
35
40
|
def drain_notify
|
36
41
|
until connection.notifies.nil?
|
37
|
-
log(:
|
42
|
+
log(:at => "drain_notifications")
|
38
43
|
end
|
39
44
|
end
|
40
45
|
|
41
46
|
def wait_for_notify(t)
|
42
47
|
connection.wait_for_notify(t) do |event, pid, msg|
|
43
|
-
log(:
|
48
|
+
log(:at => "received_notification")
|
44
49
|
end
|
45
50
|
end
|
46
51
|
|
@@ -65,22 +70,21 @@ module QC
|
|
65
70
|
|
66
71
|
def connection=(connection)
|
67
72
|
unless connection.instance_of? PG::Connection
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
)
|
73
|
+
c = connection.class
|
74
|
+
err = "connection must be an instance of PG::Connection, but was #{c}"
|
75
|
+
raise(ArgumentError, err)
|
72
76
|
end
|
73
77
|
@connection = connection
|
74
78
|
end
|
75
79
|
|
76
80
|
def disconnect
|
77
|
-
connection.finish
|
78
|
-
|
79
|
-
|
81
|
+
begin connection.finish
|
82
|
+
ensure @connection = nil
|
83
|
+
end
|
80
84
|
end
|
81
85
|
|
82
86
|
def connect
|
83
|
-
log(:
|
87
|
+
log(:at => "establish_conn")
|
84
88
|
conn = PGconn.connect(
|
85
89
|
db_url.host,
|
86
90
|
db_url.port || 5432,
|
@@ -90,7 +94,7 @@ module QC
|
|
90
94
|
db_url.password
|
91
95
|
)
|
92
96
|
if conn.status != PGconn::CONNECTION_OK
|
93
|
-
log(:
|
97
|
+
log(:error => conn.error)
|
94
98
|
end
|
95
99
|
conn
|
96
100
|
end
|
@@ -4,7 +4,7 @@ module QC
|
|
4
4
|
|
5
5
|
def insert(q_name, method, args, chan=nil)
|
6
6
|
QC.log_yield(:action => "insert_job") do
|
7
|
-
s
|
7
|
+
s="INSERT INTO #{TABLE_NAME} (q_name, method, args) VALUES ($1, $2, $3)"
|
8
8
|
res = Conn.execute(s, q_name, method, OkJson.encode(args))
|
9
9
|
Conn.notify(chan) if chan
|
10
10
|
end
|
data/lib/queue_classic/queue.rb
CHANGED
data/lib/queue_classic/tasks.rb
CHANGED
@@ -8,7 +8,10 @@ end
|
|
8
8
|
namespace :qc do
|
9
9
|
desc "Start a new worker for the (default or $QUEUE) queue"
|
10
10
|
task :work => :environment do
|
11
|
-
|
11
|
+
trap('INT') {exit}
|
12
|
+
trap('TERM') {@worker.stop}
|
13
|
+
@worker = QC::Worker.new
|
14
|
+
@worker.start
|
12
15
|
end
|
13
16
|
|
14
17
|
desc "Returns the number of jobs in the (default or QUEUE) queue"
|
data/lib/queue_classic/worker.rb
CHANGED
@@ -2,126 +2,97 @@ module QC
|
|
2
2
|
class Worker
|
3
3
|
|
4
4
|
attr_reader :queue
|
5
|
-
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
fork_worker = opts[:fork_worker] || QC::FORK_WORKER
|
14
|
-
listening_worker = opts[:listening_worker] || QC::LISTENING_WORKER
|
15
|
-
max_attempts = opts[:max_attempts] || QC::MAX_LOCK_ATTEMPTS
|
16
|
-
else
|
17
|
-
raise ArgumentError, 'wrong number of arguments (expected no args, an options hash, or 5 separate args)'
|
18
|
-
end
|
5
|
+
# In the case no arguments are passed to the initializer,
|
6
|
+
# the defaults are pulled from the environment variables.
|
7
|
+
def initialize(args={})
|
8
|
+
@q_name = args[:q_name] ||= QC::QUEUE
|
9
|
+
@top_bound = args[:top_bound] ||= QC::TOP_BOUND
|
10
|
+
@fork_worker = args[:fork_worker] ||= QC::FORK_WORKER
|
11
|
+
@listening_worker = args[:listening_worker] ||= QC::LISTENING_WORKER
|
12
|
+
@max_attempts = args[:max_attempts] ||= QC::MAX_LOCK_ATTEMPTS
|
19
13
|
|
20
14
|
@running = true
|
21
|
-
@queue = Queue.new(q_name, listening_worker)
|
22
|
-
|
23
|
-
@fork_worker = fork_worker
|
24
|
-
@listening_worker = listening_worker
|
25
|
-
@max_attempts = max_attempts
|
26
|
-
handle_signals
|
27
|
-
|
28
|
-
log(
|
29
|
-
:level => :debug,
|
30
|
-
:action => "worker_initialized",
|
31
|
-
:queue => q_name,
|
32
|
-
:top_bound => top_bound,
|
33
|
-
:fork_worker => fork_worker,
|
34
|
-
:listening_worker => listening_worker,
|
35
|
-
:max_attempts => max_attempts
|
36
|
-
)
|
37
|
-
end
|
38
|
-
|
39
|
-
def running?
|
40
|
-
@running
|
41
|
-
end
|
42
|
-
|
43
|
-
def fork_worker?
|
44
|
-
@fork_worker
|
45
|
-
end
|
46
|
-
|
47
|
-
def can_listen?
|
48
|
-
@listening_worker
|
15
|
+
@queue = Queue.new(@q_name, @listening_worker)
|
16
|
+
log(args.merge(:at => "worker_initialized"))
|
49
17
|
end
|
50
18
|
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
else
|
58
|
-
raise Interrupt
|
59
|
-
end
|
60
|
-
end
|
19
|
+
# Start a loop and work jobs indefinitely.
|
20
|
+
# Call this method to start the worker.
|
21
|
+
# This is the easiest way to start working jobs.
|
22
|
+
def start
|
23
|
+
while @running
|
24
|
+
@fork_worker ? fork_and_work : work
|
61
25
|
end
|
62
26
|
end
|
63
27
|
|
64
|
-
#
|
65
|
-
#
|
66
|
-
#
|
67
|
-
def
|
68
|
-
|
69
|
-
|
70
|
-
def start
|
71
|
-
while running?
|
72
|
-
if fork_worker?
|
73
|
-
fork_and_work
|
74
|
-
else
|
75
|
-
work
|
76
|
-
end
|
77
|
-
end
|
28
|
+
# Call this method to stop the worker.
|
29
|
+
# The worker may not stop immediately if the worker
|
30
|
+
# is sleeping.
|
31
|
+
def stop
|
32
|
+
@running = false
|
78
33
|
end
|
79
34
|
|
35
|
+
# This method will tell the ruby process to FORK.
|
36
|
+
# Define setup_child to hook into the forking process.
|
37
|
+
# Using setup_child is good for re-establishing database connections.
|
80
38
|
def fork_and_work
|
81
|
-
@cpid = fork {
|
82
|
-
log(:
|
39
|
+
@cpid = fork {setup_child; work}
|
40
|
+
log(:at => :fork, :pid => @cpid)
|
83
41
|
Process.wait(@cpid)
|
84
42
|
end
|
85
43
|
|
44
|
+
# This method will lock a job & evaluate the code defined by the job.
|
45
|
+
# Also, this method will make the best attempt to delete the job
|
46
|
+
# from the queue before returning.
|
86
47
|
def work
|
87
48
|
if job = lock_job
|
88
|
-
QC.log_yield(:
|
49
|
+
QC.log_yield(:at => "work", :job => job[:id]) do
|
89
50
|
begin
|
90
51
|
call(job)
|
91
|
-
rescue
|
92
|
-
log(:level => :debug, :action => "failed_work", :job => job[:id], :error => e.inspect)
|
52
|
+
rescue => e
|
93
53
|
handle_failure(job, e)
|
94
54
|
ensure
|
95
55
|
@queue.delete(job[:id])
|
96
|
-
log(:
|
56
|
+
log(:at => "delete_job", :job => job[:id])
|
97
57
|
end
|
98
58
|
end
|
99
59
|
end
|
100
60
|
end
|
101
61
|
|
62
|
+
# lock_job will attempt to lock a job in the queue's table. It uses an
|
63
|
+
# exponential backoff in the event that a job was not locked. This method
|
64
|
+
# will return a hash when a job is obtained.
|
65
|
+
#
|
66
|
+
# This method will terminate early if the stop method is called or
|
67
|
+
# @max_attempts has been reached.
|
68
|
+
#
|
69
|
+
# It is important that callers delete the job when finished.
|
70
|
+
# *@queue.delete(job[:id])*
|
102
71
|
def lock_job
|
103
|
-
log(:
|
72
|
+
log(:at => "lock_job")
|
104
73
|
attempts = 0
|
105
74
|
job = nil
|
106
|
-
until
|
75
|
+
until !@running || job
|
107
76
|
job = @queue.lock(@top_bound)
|
108
77
|
if job.nil?
|
109
|
-
log(:
|
78
|
+
log(:at => "failed_lock", :attempts => attempts)
|
110
79
|
if attempts < @max_attempts
|
111
|
-
|
112
|
-
wait(seconds)
|
80
|
+
wait(2**attempts)
|
113
81
|
attempts += 1
|
114
82
|
next
|
115
83
|
else
|
116
84
|
break
|
117
85
|
end
|
118
86
|
else
|
119
|
-
log(:
|
87
|
+
log(:at => "finished_lock", :job => job[:id])
|
120
88
|
end
|
121
89
|
end
|
122
90
|
job
|
123
91
|
end
|
124
92
|
|
93
|
+
# Each job includes a method column. We will use ruby's eval
|
94
|
+
# to grab the ruby object from memory. We send the method to
|
95
|
+
# the object and pass the args.
|
125
96
|
def call(job)
|
126
97
|
args = job[:args]
|
127
98
|
klass = eval(job[:method].split(".").first)
|
@@ -129,27 +100,35 @@ module QC
|
|
129
100
|
klass.send(message, *args)
|
130
101
|
end
|
131
102
|
|
103
|
+
# If @listening_worker is set, the worker will use the database
|
104
|
+
# to sleep. The database approach preferred over a syscall since
|
105
|
+
# the database will break the sleep when new jobs are inserted into
|
106
|
+
# the queue.
|
132
107
|
def wait(t)
|
133
|
-
if
|
134
|
-
log(:
|
108
|
+
if @listening_worker
|
109
|
+
log(:at => "listen_wait", :wait => t)
|
135
110
|
Conn.listen(@queue.chan)
|
136
111
|
Conn.wait_for_notify(t)
|
137
112
|
Conn.unlisten(@queue.chan)
|
138
113
|
Conn.drain_notify
|
139
|
-
log(:
|
114
|
+
log(:at => "finished_listening")
|
140
115
|
else
|
141
|
-
log(:
|
116
|
+
log(:at => "sleep_wait", :wait => t)
|
142
117
|
Kernel.sleep(t)
|
143
118
|
end
|
144
119
|
end
|
145
120
|
|
146
|
-
#
|
121
|
+
# This method will be called when an exception
|
122
|
+
# is raised during the execution of the job.
|
147
123
|
def handle_failure(job,e)
|
148
|
-
|
149
|
-
|
150
|
-
|
151
|
-
|
152
|
-
|
124
|
+
log(:at => "handle_failure")
|
125
|
+
end
|
126
|
+
|
127
|
+
# This method should be overriden if
|
128
|
+
# your worker is forking and you need to
|
129
|
+
# re-establish database connections
|
130
|
+
def setup_child
|
131
|
+
log(:at => "setup_child")
|
153
132
|
end
|
154
133
|
|
155
134
|
def log(data)
|
data/readme.md
CHANGED
@@ -1,175 +1,34 @@
|
|
1
1
|
# queue_classic
|
2
2
|
|
3
|
-
v2.0
|
3
|
+
v2.1.0
|
4
4
|
|
5
|
-
queue_classic provides PostgreSQL-backed
|
6
|
-
locking and minimizing database load while providing a simple, intuitive user
|
7
|
-
experience.
|
5
|
+
queue_classic provides a simple interface to a PostgreSQL-backed message queue. queue_classic specializes in concurrent locking and minimizing database load while providing a simple, intuitive developer experience. queue_classic assumes that you are already using PostgreSQL in your production environment and that adding another dependency (e.g. redis, beanstalkd, 0mq) is undesirable.
|
8
6
|
|
9
|
-
|
7
|
+
Features:
|
10
8
|
|
11
|
-
*
|
12
|
-
*
|
13
|
-
* JSON
|
14
|
-
* Forking workers
|
15
|
-
*
|
16
|
-
* Fuzzy-FIFO support [academic paper](http://www.cs.tau.ac.il/~shanir/nir-pubs-web/Papers/Lock_Free.pdf)
|
17
|
-
* Instrumentation via log output
|
18
|
-
* Long term support
|
9
|
+
* Leverage of PostgreSQL's listen/notify & row locking.
|
10
|
+
* Support for multiple queues with heterogeneous workers.
|
11
|
+
* JSON data format.
|
12
|
+
* Forking workers.
|
13
|
+
* [Fuzzy-FIFO support](http://www.cs.tau.ac.il/~shanir/nir-pubs-web/Papers/Lock_Free.pdf)
|
19
14
|
|
20
|
-
|
15
|
+
Contents:
|
21
16
|
|
22
|
-
queue_classic
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
more advanced queueing features, you should investigate 0MQ, rabbitmq, or redis.
|
29
|
-
|
30
|
-
### Heroku Postgres
|
31
|
-
|
32
|
-
The Heroku Postgres team uses queue_classic to monitor the health of
|
33
|
-
customer databases, processing 200 jobs per second using a [fugu](https://postgres.heroku.com/pricing)
|
34
|
-
database. They chose queue_classic because of its simplicity and reliability.
|
35
|
-
|
36
|
-
### Cloudapp
|
37
|
-
|
38
|
-
Larry uses queue_classic to deliver cloudapp's push notifications and collect
|
39
|
-
file meta-data from S3, processing nearly 14 jobs per second.
|
40
|
-
|
41
|
-
```
|
42
|
-
I haven't even touched QC since setting it up.
|
43
|
-
The best queue is the one you don't have to hand hold.
|
44
|
-
|
45
|
-
-- Larry Marburger
|
46
|
-
```
|
47
|
-
|
48
|
-
## Setup
|
49
|
-
|
50
|
-
In addition to installing the rubygem, you will need to prepare your database.
|
51
|
-
Database preparation includes creating a table and loading PL/pgSQL functions.
|
52
|
-
You can issue the database preparation commands using **PSQL(1)** or place them in a
|
53
|
-
database migration.
|
54
|
-
|
55
|
-
### Quick Start
|
56
|
-
|
57
|
-
```bash
|
58
|
-
$ gem install queue_classic
|
59
|
-
$ createdb queue_classic_test
|
60
|
-
$ export QC_DATABASE_URL="postgres://username:password@localhost/queue_classic_test"
|
61
|
-
$ ruby -r queue_classic -e "QC::Setup.create"
|
62
|
-
$ ruby -r queue_classic -e "QC.enqueue('Kernel.puts', 'hello world')"
|
63
|
-
$ ruby -r queue_classic -e "QC::Worker.new.work"
|
64
|
-
```
|
65
|
-
|
66
|
-
### Ruby on Rails Setup
|
67
|
-
|
68
|
-
**Gemfile**
|
69
|
-
|
70
|
-
```ruby
|
71
|
-
source :rubygems
|
72
|
-
gem "queue_classic", "2.0.1"
|
73
|
-
```
|
74
|
-
|
75
|
-
**Rakefile**
|
76
|
-
|
77
|
-
```ruby
|
78
|
-
require "queue_classic"
|
79
|
-
require "queue_classic/tasks"
|
80
|
-
```
|
81
|
-
|
82
|
-
**config/initializers/queue_classic.rb**
|
83
|
-
|
84
|
-
```ruby
|
85
|
-
# queue_classic will by default look for an environment variable DATABASE_URL
|
86
|
-
# or QC_DATABASE_URL for a connection string in the format
|
87
|
-
# "postgres://username:password@localhost/database_name". If you use Heroku,
|
88
|
-
# this will already be set.
|
89
|
-
#
|
90
|
-
# If you don't want to set this variable, you can set the connection in an
|
91
|
-
# initializer.
|
92
|
-
QC::Conn.connection = ActiveRecord::Base.connection.raw_connection
|
93
|
-
```
|
94
|
-
|
95
|
-
queue_classic requires a database table and a PL/pgSQL function to be loaded
|
96
|
-
into your database. You can load the table and the function by running a migration
|
97
|
-
or using a rake task.
|
98
|
-
|
99
|
-
**db/migrations/add_queue_classic.rb**
|
100
|
-
|
101
|
-
If you use the migration, and you wish to use commands that reset the database
|
102
|
-
from the stored schema (e.g. `rake db:reset`), your application must be
|
103
|
-
configured with `config.active_record.schema_format = :sql` in
|
104
|
-
`config/application.rb`. If you don't do this, the PL/pgSQL function that
|
105
|
-
queue_classic creates will be lost when you reset the database.
|
106
|
-
|
107
|
-
```ruby
|
108
|
-
require 'queue_classic'
|
109
|
-
|
110
|
-
class AddQueueClassic < ActiveRecord::Migration
|
111
|
-
|
112
|
-
def self.up
|
113
|
-
QC::Setup.create
|
114
|
-
end
|
115
|
-
|
116
|
-
def self.down
|
117
|
-
QC::Setup.drop
|
118
|
-
end
|
119
|
-
|
120
|
-
end
|
121
|
-
```
|
122
|
-
|
123
|
-
**Rake Task**
|
124
|
-
|
125
|
-
```bash
|
126
|
-
# Creating the table and functions
|
127
|
-
$ bundle exec rake qc:create
|
128
|
-
|
129
|
-
# Dropping the table and functions
|
130
|
-
$ bundle exec rake qc:drop
|
131
|
-
```
|
132
|
-
|
133
|
-
|
134
|
-
### Sequel Setup
|
135
|
-
|
136
|
-
**db/migrations/1_add_queue_classic.rb**
|
137
|
-
|
138
|
-
```ruby
|
139
|
-
require 'queue_classic'
|
140
|
-
|
141
|
-
Sequel.migration do
|
142
|
-
up {QC::Setup.create}
|
143
|
-
down {QC::Setup.drop}
|
144
|
-
end
|
145
|
-
```
|
146
|
-
|
147
|
-
## Configure
|
148
|
-
|
149
|
-
All configuration takes place in the form of environment vars.
|
150
|
-
See [queue_classic.rb](https://github.com/ryandotsmith/queue_classic/blob/master/lib/queue_classic.rb#L23-62)
|
151
|
-
for a list of options.
|
17
|
+
* [Documentation](http://rubydoc.info/gems/queue_classic/2.1.0/frames)
|
18
|
+
* [Usage](#usage)
|
19
|
+
* [Setup](#setup)
|
20
|
+
* [Configuration](#configuration)
|
21
|
+
* [Hacking](#hacking-on-queue_classic)
|
22
|
+
* [License](#license)
|
152
23
|
|
153
24
|
## Usage
|
154
25
|
|
155
|
-
|
156
|
-
consuming jobs (lock then delete).
|
26
|
+
There are 2 ways to use queue_classic.
|
157
27
|
|
158
|
-
|
28
|
+
* Producing Jobs
|
29
|
+
* Working Jobs
|
159
30
|
|
160
|
-
|
161
|
-
|
162
|
-
```bash
|
163
|
-
$ psql queue_classic_test -c "INSERT INTO queue_classic_jobs (q_name, method, args) VALUES ('default', 'Kernel.puts', '[\"hello world\"]');"
|
164
|
-
```
|
165
|
-
|
166
|
-
However, the rubygem will take care of converting your args to JSON and it will also dispatch
|
167
|
-
PUB/SUB notifications if the feature is enabled. It will also manage a connection to the database
|
168
|
-
that is independent of any other connection you may have in your application. Note: If your
|
169
|
-
queue table is in your application's database then your application's process will have 2 connections
|
170
|
-
to the database; one for your application and another for queue_classic.
|
171
|
-
|
172
|
-
The Ruby API for producing jobs is pretty simple:
|
31
|
+
### Producing Jobs
|
173
32
|
|
174
33
|
```ruby
|
175
34
|
# This method has no arguments.
|
@@ -186,380 +45,151 @@ QC.enqueue("Kernel.puts", {"hello" => "world"})
|
|
186
45
|
|
187
46
|
# This method has an array argument.
|
188
47
|
QC.enqueue("Kernel.puts", ["hello", "world"])
|
48
|
+
|
49
|
+
# This method uses a non-default queue.
|
50
|
+
p_queue = QC::Queue.new(q_name: "priority_queue")
|
51
|
+
p_queue.enqueue("Kernel.puts", ["hello", "world"])
|
189
52
|
```
|
190
53
|
|
191
|
-
|
192
|
-
is used to encode the arguments, so the arguments can be anything that OkJson can encode.
|
54
|
+
QueueClassic uses [OkJson](https://github.com/kr/okjson) to encode the job's payload.
|
193
55
|
|
194
56
|
```ruby
|
195
|
-
# Won't work!
|
196
|
-
OkJson.encode({:test => "test"})
|
197
|
-
|
198
57
|
# OK
|
199
58
|
OkJson.encode({"test" => "test"})
|
200
|
-
```
|
201
|
-
|
202
|
-
To see more information on usage, take a look at the test files in the source code. Also,
|
203
|
-
read up on [OkJson](https://github.com/kr/okjson)
|
204
|
-
|
205
|
-
#### Multiple Queues
|
206
|
-
|
207
|
-
The table containing the jobs has a column named *q_name*. This column
|
208
|
-
is the abstraction queue_classic uses to represent multiple queues. This allows
|
209
|
-
the programmer to place triggers and indexes on distinct queues.
|
210
|
-
|
211
|
-
```ruby
|
212
|
-
# attach to the priority_queue. this will insert
|
213
|
-
# jobs with the column q_name = 'priority_queue'
|
214
|
-
p_queue = QC::Queue.new("priority_queue")
|
215
|
-
|
216
|
-
# This method has no arguments.
|
217
|
-
p_queue.enqueue("Time.now")
|
218
|
-
|
219
|
-
# This method has 1 argument.
|
220
|
-
p_queue.enqueue("Kernel.puts", "hello world")
|
221
|
-
|
222
|
-
# This method has 2 arguments.
|
223
|
-
p_queue.enqueue("Kernel.printf", "hello %s", "world")
|
224
59
|
|
225
|
-
#
|
226
|
-
|
227
|
-
|
228
|
-
# This method has an array argument.
|
229
|
-
p_queue.enqueue("Kernel.puts", ["hello", "world"])
|
60
|
+
# NOT OK
|
61
|
+
OkJson.encode({:test => "test"})
|
230
62
|
```
|
231
63
|
|
232
|
-
|
233
|
-
to consume jobs from the custom queue be sure and set the `$QUEUE`
|
234
|
-
var to the q_name in the worker's UNIX environment.
|
235
|
-
|
236
|
-
### Consumer
|
64
|
+
### Working Jobs
|
237
65
|
|
238
|
-
There are
|
239
|
-
a task file provided by queue_classic and the other approach is to
|
240
|
-
write a custom bin file.
|
66
|
+
There are two ways to work jobs. The first approach is to use the Rake task. The second approach is to use a custom executable.
|
241
67
|
|
242
68
|
#### Rake Task
|
243
69
|
|
244
|
-
|
245
|
-
|
70
|
+
Require queue_classic in your Rakefile.
|
71
|
+
|
72
|
+
```ruby
|
73
|
+
require 'queue_classic'
|
74
|
+
require 'queue_classic/tasks'
|
75
|
+
```
|
246
76
|
|
247
|
-
|
77
|
+
Start the worker via the Rakefile.
|
248
78
|
|
249
79
|
```bash
|
250
80
|
$ bundle exec rake qc:work
|
251
81
|
```
|
252
|
-
|
82
|
+
|
83
|
+
Setup a worker to work a non-default queue.
|
253
84
|
|
254
85
|
```bash
|
255
86
|
$ QUEUE="p_queue" bundle exec rake qc:work
|
256
87
|
```
|
257
88
|
|
258
|
-
####
|
89
|
+
#### Custom Worker
|
259
90
|
|
260
|
-
|
261
|
-
Then add an executable file called worker.
|
262
|
-
|
263
|
-
**bin/worker**
|
91
|
+
This example is probably not production ready; however, it serves as an example of how to leverage the code in the Worker class to fit your non-default requirements.
|
264
92
|
|
265
93
|
```ruby
|
266
|
-
|
267
|
-
|
268
|
-
|
269
|
-
trap('INT') {exit}
|
270
|
-
trap('TERM') {exit}
|
271
|
-
|
272
|
-
require "your_app"
|
273
|
-
require "queue_classic"
|
274
|
-
QC::Worker.new.start
|
275
|
-
```
|
276
|
-
|
277
|
-
#### Subclass QC::Worker
|
94
|
+
require 'timeout'
|
95
|
+
require 'queue_classic'
|
278
96
|
|
279
|
-
|
280
|
-
|
281
|
-
that you will want to override. For instance, if you are using a forking worker, you will need to
|
282
|
-
open a new database connection in the child process that is doing your work. Also, you may want to
|
283
|
-
define how a failed job should behave. The default failed handler will simply print the job to stdout.
|
284
|
-
You can define a failure method that will enqueue the job again, or move it to another table, etc....
|
97
|
+
trap('INT') {exit}
|
98
|
+
trap('TERM') {worker.stop}
|
285
99
|
|
286
|
-
|
287
|
-
require "queue_classic"
|
100
|
+
FailedQueue = QC::Queue.new("failed_jobs")
|
288
101
|
|
289
102
|
class MyWorker < QC::Worker
|
290
|
-
|
291
|
-
|
292
|
-
|
293
|
-
@queue.enqueue(job[:method], *job[:args])
|
294
|
-
end
|
295
|
-
|
296
|
-
# the forked proc needs a new db connection
|
297
|
-
def setup_child
|
298
|
-
ActiveRecord::Base.establish_connection
|
299
|
-
end
|
300
|
-
|
103
|
+
def handle_failure(job, e)
|
104
|
+
FailedQueue.enqueue(job)
|
105
|
+
end
|
301
106
|
end
|
302
|
-
```
|
303
107
|
|
304
|
-
|
305
|
-
|
306
|
-
|
307
|
-
|
308
|
-
|
309
|
-
|
310
|
-
|
311
|
-
|
312
|
-
|
313
|
-
|
314
|
-
|
315
|
-
|
316
|
-
|
317
|
-
|
318
|
-
require "my_worker"
|
319
|
-
|
320
|
-
MyWorker.new.start
|
108
|
+
worker = MyWorker.new(max_attempts: 10, listening_worker: true)
|
109
|
+
|
110
|
+
loop do
|
111
|
+
job = worker.lock_job
|
112
|
+
Thread.new do
|
113
|
+
begin
|
114
|
+
Timeout::timeout(5) {worker.call(job)}
|
115
|
+
rescue => e
|
116
|
+
handle_failure(job, e)
|
117
|
+
ensure
|
118
|
+
worker.delete(job[:id])
|
119
|
+
end
|
120
|
+
end
|
121
|
+
end
|
321
122
|
```
|
322
123
|
|
323
|
-
|
324
|
-
|
325
|
-
##### General Idea
|
326
|
-
|
327
|
-
The worker class (QC::Worker) is designed to be extended via inheritance. Any of
|
328
|
-
its methods should be considered for extension. There are a few in particular
|
329
|
-
that act as stubs in hopes that the user will override them. Such methods
|
330
|
-
include: `handle_failure() and setup_child()`. See the section near the bottom
|
331
|
-
for a detailed descriptor of how to subclass the worker.
|
332
|
-
|
333
|
-
##### Algorithm
|
334
|
-
|
335
|
-
When we ask the worker to start, it will enter a loop with a stop condition
|
336
|
-
dependent upon a method named `running?` . While in the method, the worker will
|
337
|
-
attempt to select and lock a job. If it can not on its first attempt, it will
|
338
|
-
use an exponential back-off technique to try again.
|
339
|
-
|
340
|
-
##### Signals
|
341
|
-
|
342
|
-
*INT, TERM* Both of these signals will ensure that the running? method returns
|
343
|
-
false. If the worker is waiting -- as it does per the exponential backoff
|
344
|
-
technique; then a second signal must be sent.
|
345
|
-
|
346
|
-
##### Forking
|
124
|
+
## Setup
|
347
125
|
|
348
|
-
|
349
|
-
An argument against forking may be that you want low latency in your job
|
350
|
-
execution. An argument in favor of forking is that your jobs leak memory and do
|
351
|
-
all sorts of crazy things, thus warranting the cleanup that fork allows.
|
352
|
-
Nevertheless, forking is not enabled by default. To instruct your worker to
|
353
|
-
fork, ensure the following shell variable is set:
|
126
|
+
In addition to installing the rubygem, you will need to prepare your database. Database preparation includes creating a table and loading PL/pgSQL functions. You can issue the database preparation commands using `PSQL(1)` or use a database migration script.
|
354
127
|
|
355
|
-
|
356
|
-
$ export QC_FORK_WORKER='true'
|
357
|
-
```
|
358
|
-
|
359
|
-
One last note on forking. It is often the case that after Ruby forks a process,
|
360
|
-
some sort of setup needs to be done. For instance, you may want to re-establish
|
361
|
-
a database connection, or get a new file descriptor. queue_classic's worker
|
362
|
-
provides a hook that is called immediately after `Kernel.fork`. To use this hook
|
363
|
-
subclass the worker and override `setup_child()`.
|
364
|
-
|
365
|
-
##### LISTEN/NOTIFY
|
366
|
-
|
367
|
-
The exponential back-off algorithm will require our worker to wait if it does
|
368
|
-
not succeed in locking a job. How we wait is something that can vary. PostgreSQL
|
369
|
-
has a wonderful feature that we can use to wait intelligently. Processes can LISTEN on a channel and be
|
370
|
-
alerted to notifications. queue_classic uses this feature to block until a
|
371
|
-
notification is received. If this feature is disabled, the worker will call
|
372
|
-
`Kernel.sleep(t)` where t is set by our exponential back-off algorithm. However,
|
373
|
-
if we are using LISTEN/NOTIFY then we can enter a type of sleep that can be
|
374
|
-
interrupted by a NOTIFY. For example, say we just started to wait for 2 seconds.
|
375
|
-
After the first millisecond of waiting, a job was enqueued. With LISTEN/NOTIFY
|
376
|
-
enabled, our worker would immediately preempt the wait and attempt to lock the job. This
|
377
|
-
allows our worker to be much more responsive. In the case there is no
|
378
|
-
notification, the worker will quit waiting after the timeout has expired.
|
379
|
-
|
380
|
-
LISTEN/NOTIFY is disabled by default but can be enabled by setting the following shell variable:
|
128
|
+
### Quick Start
|
381
129
|
|
382
130
|
```bash
|
383
|
-
$
|
131
|
+
$ gem install queue_classic
|
132
|
+
$ createdb queue_classic_test
|
133
|
+
$ export QC_DATABASE_URL="postgres://username:password@localhost/queue_classic_test"
|
134
|
+
$ ruby -r queue_classic -e "QC::Setup.create"
|
135
|
+
$ ruby -r queue_classic -e "QC.enqueue('Kernel.puts', 'hello world')"
|
136
|
+
$ ruby -r queue_classic -e "QC::Worker.new.work"
|
384
137
|
```
|
385
138
|
|
386
|
-
|
387
|
-
|
388
|
-
I bet your worker will encounter a job that raises an exception. queue_classic
|
389
|
-
thinks that you should know about this exception by means of you established
|
390
|
-
exception tracker. (i.e. Hoptoad, Exceptional) To that end, queue_classic offers
|
391
|
-
a method that you can override. This method will be passed 2 arguments: the
|
392
|
-
exception instance and the job. Here are a few examples of things you might want
|
393
|
-
to do inside `handle_failure()`.
|
394
|
-
|
395
|
-
## Instrumentation
|
139
|
+
### Ruby on Rails Setup
|
396
140
|
|
397
|
-
|
398
|
-
To customize the output of the log data, override `QC.log(data)` and `QC.log_yield(data)`.
|
399
|
-
By default, QC uses a simple wrapper around $stdout to put the log data in k=v
|
400
|
-
format. For instance:
|
141
|
+
Declare dependencies in Gemfile.
|
401
142
|
|
143
|
+
```ruby
|
144
|
+
source :rubygems
|
145
|
+
gem "queue_classic", "2.1.0"
|
402
146
|
```
|
403
|
-
lib=queue_classic level=info action=insert_job elapsed=16
|
404
|
-
```
|
405
|
-
|
406
|
-
## Tips and Tricks
|
407
|
-
|
408
|
-
### Running Synchronously for tests
|
409
|
-
|
410
|
-
Author: [@em_csquared](https://twitter.com/#!/em_csquared)
|
411
147
|
|
412
|
-
|
413
|
-
wanted to move that work over to a queue. After completing a red-green-refactor
|
414
|
-
I did not want my tests to have to worry about workers or even hit the database.
|
415
|
-
|
416
|
-
Turns out its easy to get queue_classic to just work in a synchronous way with:
|
148
|
+
Require these files in your Rakefile so that you can run `rake qc:work`.
|
417
149
|
|
418
150
|
```ruby
|
419
|
-
|
420
|
-
|
421
|
-
end
|
151
|
+
require "queue_classic"
|
152
|
+
require "queue_classic/tasks"
|
422
153
|
```
|
423
154
|
|
424
|
-
|
425
|
-
|
426
|
-
|
427
|
-
### Dispatching new jobs to workers without new code
|
428
|
-
|
429
|
-
Author: [@ryandotsmith (ace hacker)](https://twitter.com/#!/ryandotsmith)
|
430
|
-
|
431
|
-
The other day I found myself in a position in which I needed to delete a few
|
432
|
-
thousand records. The tough part of this situation is that I needed to ensure
|
433
|
-
the ActiveRecord callbacks were made on these objects thus making a simple SQL
|
434
|
-
statement unfeasible. Also, I didn't want to wait all day to select and destroy
|
435
|
-
these objects. queue_classic to the rescue! (no pun intended)
|
436
|
-
|
437
|
-
The API of queue_classic enables you to quickly dispatch jobs to workers. In my
|
438
|
-
case I wanted to call `Invoice.destroy(id)` a few thousand times. I fired up a
|
439
|
-
Heroku console session and executed this line:
|
155
|
+
By default, queue_classic will use the QC_DATABASE_URL falling back on DATABASE_URL. The URL must be in the following format: `postgres://username:password@localhost/database_name`. If you use Heroku's PostgreSQL service, this will already be set. If you don't want to set this variable, you can set the connection in an initializer. **QueueClassic will maintain its own connection to the database.** This may double the number of connections to your database. Set QC::Conn.connection to share the connection between Rails & QueueClassic
|
440
156
|
|
441
157
|
```ruby
|
442
|
-
|
158
|
+
require 'queue_classic'
|
159
|
+
QC::Conn.connection = ActiveRecord::Base.connection.raw_connection
|
443
160
|
```
|
444
161
|
|
445
|
-
|
446
|
-
(preserving their callbacks) in a few minutes.
|
447
|
-
|
448
|
-
### Enqueueing batches of jobs
|
449
|
-
|
450
|
-
Author: [@ryandotsmith (ace hacker)](https://twitter.com/#!/ryandotsmith)
|
451
|
-
|
452
|
-
I have seen several cases where the application will enqueue jobs in batches. For instance, you may be sending
|
453
|
-
1,000 emails out. In this case, it would be foolish to do 1,000 individual transaction. Instead, you want to open
|
454
|
-
a new transaction, enqueue all of your jobs and then commit the transaction. This will save tons of time in the
|
455
|
-
database.
|
162
|
+
**Note on using ActiveRecord migrations:** If you use the migration, and you wish to use commands that reset the database from the stored schema (e.g. `rake db:reset`), your application must be configured with `config.active_record.schema_format = :sql` in `config/application.rb`. If you don't do this, the PL/pgSQL function that queue_classic creates will be lost when you reset the database.
|
456
163
|
|
457
|
-
|
164
|
+
```ruby
|
165
|
+
require 'queue_classic'
|
458
166
|
|
459
|
-
|
167
|
+
class AddQueueClassic < ActiveRecord::Migration
|
168
|
+
def self.up
|
169
|
+
QC::Setup.create
|
170
|
+
end
|
460
171
|
|
461
|
-
|
462
|
-
QC::
|
463
|
-
Account.all.each do |act|
|
464
|
-
QC.enqueue("Emailer.send_notice", act.id)
|
172
|
+
def self.down
|
173
|
+
QC::Setup.drop
|
465
174
|
end
|
466
175
|
end
|
467
176
|
```
|
468
177
|
|
469
|
-
###
|
470
|
-
|
471
|
-
Author: [@ryandotsmith (ace hacker)](https://twitter.com/#!/ryandotsmith)
|
178
|
+
### Rake Task Setup
|
472
179
|
|
473
|
-
|
474
|
-
Redis-Scheduler and the run_at column in DJ are very important to the web
|
475
|
-
application developer. While queue_classic does not offer any sort of scheduling
|
476
|
-
features, I do not discount the importance of the concept. However, it is my
|
477
|
-
belief that a scheduler has no place in a queueing library, to that end I will
|
478
|
-
show you how to schedule jobs using queue_classic and the clockwork gem.
|
479
|
-
|
480
|
-
#### Example
|
481
|
-
|
482
|
-
In this example, we are working with a system that needs to compute a sales
|
483
|
-
summary at the end of each day. Lets say that we need to compute a summary for
|
484
|
-
each sales employee in the system.
|
485
|
-
|
486
|
-
Instead of enqueueing jobs with run_at set to 24hour intervals,
|
487
|
-
we will define a clock process to enqueue the jobs at a specified
|
488
|
-
time on each day. Let us create a file and call it clock.rb:
|
489
|
-
|
490
|
-
```ruby
|
491
|
-
handler {|job| QC.enqueue(job)}
|
492
|
-
every(1.day, "SalesSummaryGenerator.build_daily_report", :at => "01:00")
|
493
|
-
```
|
494
|
-
|
495
|
-
To start our scheduler, we will use the clockwork bin:
|
180
|
+
Alternatively, you can use the Rake task to prepare your database.
|
496
181
|
|
497
182
|
```bash
|
498
|
-
|
499
|
-
|
500
|
-
|
501
|
-
Now each day at 01:00 we will be sending the build_daily_report message to our
|
502
|
-
SalesSummaryGenerator class.
|
503
|
-
|
504
|
-
I found this abstraction quite powerful and easy to understand. Like
|
505
|
-
queue_classic, the clockwork gem is simple to understand and has 0 dependencies.
|
506
|
-
In production, I create a Heroku process type called clock. This is typically
|
507
|
-
what my Procfile looks like:
|
508
|
-
|
509
|
-
```
|
510
|
-
worker: rake jobs:work
|
511
|
-
clock: clockwork clock.rb
|
512
|
-
```
|
513
|
-
|
514
|
-
## Upgrading From Older Versions
|
515
|
-
|
516
|
-
### 1.X to 2.X
|
517
|
-
|
518
|
-
#### Database Schema Changes
|
519
|
-
|
520
|
-
* all queues are in 1 table with a q_name column
|
521
|
-
* table includes a method column and an args column
|
522
|
-
|
523
|
-
#### Producer Changes
|
524
|
-
|
525
|
-
* initializing a Queue instance takes a column name instead of a table name
|
526
|
-
|
527
|
-
#### Consumer Changes
|
528
|
-
|
529
|
-
* all of the worker configuratoin is passed in through the initializer
|
530
|
-
* rake task uses data from env vars to initialize a worker
|
531
|
-
|
532
|
-
### 0.2.X to 0.3.X
|
533
|
-
|
534
|
-
* Deprecated QC.queue_length in favor of QC.length
|
535
|
-
* Locking functions need to be loaded into database via `$ rake qc:load_functions`
|
536
|
-
|
537
|
-
Also, the default queue is no longer named jobs,
|
538
|
-
it is named queue_classic_jobs. Renaming the table is the only change that needs to be made.
|
183
|
+
# Creating the table and functions
|
184
|
+
$ bundle exec rake qc:create
|
539
185
|
|
540
|
-
|
541
|
-
$
|
186
|
+
# Dropping the table and functions
|
187
|
+
$ bundle exec rake qc:drop
|
542
188
|
```
|
543
189
|
|
544
|
-
|
545
|
-
|
546
|
-
```ruby
|
547
|
-
class RenameJobsTable < ActiveRecord::Migration
|
548
|
-
|
549
|
-
def self.up
|
550
|
-
rename_table :jobs, :queue_classic_jobs
|
551
|
-
remove_index :jobs, :id
|
552
|
-
add_index :queue_classic_jobs, :id
|
553
|
-
end
|
554
|
-
|
555
|
-
def self.down
|
556
|
-
rename_table :queue_classic_jobs, :jobs
|
557
|
-
remove_index :queue_classic_jobs, :id
|
558
|
-
add_index :jobs, :id
|
559
|
-
end
|
190
|
+
## Configuration
|
560
191
|
|
561
|
-
|
562
|
-
```
|
192
|
+
All configuration takes place in the form of environment vars. See [queue_classic.rb](https://github.com/ryandotsmith/queue_classic/blob/master/lib/queue_classic.rb#L23-62) for a list of options.
|
563
193
|
|
564
194
|
## Hacking on queue_classic
|
565
195
|
|
@@ -579,6 +209,12 @@ $ export QC_DATABASE_URL="postgres://username:pass@localhost/queue_classic_test"
|
|
579
209
|
$ rake
|
580
210
|
```
|
581
211
|
|
582
|
-
|
212
|
+
## License
|
213
|
+
|
214
|
+
Copyright (C) 2010 Ryan Smith
|
215
|
+
|
216
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
217
|
+
|
218
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
583
219
|
|
584
|
-
|
220
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/test/worker_test.rb
CHANGED
@@ -28,7 +28,7 @@ class WorkerTest < QCTest
|
|
28
28
|
|
29
29
|
def test_work
|
30
30
|
QC.enqueue("TestObject.no_args")
|
31
|
-
worker = TestWorker.new
|
31
|
+
worker = TestWorker.new
|
32
32
|
assert_equal(1, QC.count)
|
33
33
|
worker.work
|
34
34
|
assert_equal(0, QC.count)
|
@@ -37,14 +37,14 @@ class WorkerTest < QCTest
|
|
37
37
|
|
38
38
|
def test_failed_job
|
39
39
|
QC.enqueue("TestObject.not_a_method")
|
40
|
-
worker = TestWorker.new
|
40
|
+
worker = TestWorker.new
|
41
41
|
worker.work
|
42
42
|
assert_equal(1, worker.failed_count)
|
43
43
|
end
|
44
44
|
|
45
45
|
def test_work_with_no_args
|
46
46
|
QC.enqueue("TestObject.no_args")
|
47
|
-
worker = TestWorker.new
|
47
|
+
worker = TestWorker.new
|
48
48
|
r = worker.work
|
49
49
|
assert_nil(r)
|
50
50
|
assert_equal(0, worker.failed_count)
|
@@ -52,7 +52,7 @@ class WorkerTest < QCTest
|
|
52
52
|
|
53
53
|
def test_work_with_one_arg
|
54
54
|
QC.enqueue("TestObject.one_arg", "1")
|
55
|
-
worker = TestWorker.new
|
55
|
+
worker = TestWorker.new
|
56
56
|
r = worker.work
|
57
57
|
assert_equal("1", r)
|
58
58
|
assert_equal(0, worker.failed_count)
|
@@ -60,7 +60,7 @@ class WorkerTest < QCTest
|
|
60
60
|
|
61
61
|
def test_work_with_two_args
|
62
62
|
QC.enqueue("TestObject.two_args", "1", 2)
|
63
|
-
worker = TestWorker.new
|
63
|
+
worker = TestWorker.new
|
64
64
|
r = worker.work
|
65
65
|
assert_equal(["1", 2], r)
|
66
66
|
assert_equal(0, worker.failed_count)
|
@@ -69,7 +69,7 @@ class WorkerTest < QCTest
|
|
69
69
|
def test_work_custom_queue
|
70
70
|
p_queue = QC::Queue.new("priority_queue")
|
71
71
|
p_queue.enqueue("TestObject.two_args", "1", 2)
|
72
|
-
worker = TestWorker.new("priority_queue"
|
72
|
+
worker = TestWorker.new(q_name: "priority_queue")
|
73
73
|
r = worker.work
|
74
74
|
assert_equal(["1", 2], r)
|
75
75
|
assert_equal(0, worker.failed_count)
|
@@ -78,7 +78,7 @@ class WorkerTest < QCTest
|
|
78
78
|
def test_worker_listens_on_chan
|
79
79
|
p_queue = QC::Queue.new("priority_queue")
|
80
80
|
p_queue.enqueue("TestObject.two_args", "1", 2)
|
81
|
-
worker = TestWorker.new("priority_queue",
|
81
|
+
worker = TestWorker.new(q_name: "priority_queue", listening_worker: true)
|
82
82
|
r = worker.work
|
83
83
|
assert_equal(["1", 2], r)
|
84
84
|
assert_equal(0, worker.failed_count)
|
@@ -86,7 +86,7 @@ class WorkerTest < QCTest
|
|
86
86
|
|
87
87
|
def test_worker_ueses_one_conn
|
88
88
|
QC.enqueue("TestObject.no_args")
|
89
|
-
worker = TestWorker.new
|
89
|
+
worker = TestWorker.new
|
90
90
|
worker.work
|
91
91
|
assert_equal(
|
92
92
|
1,
|