af_minion 0.1.15.1

Sign up to get free protection for your applications and to get access to all the features.
data/README.rdoc ADDED
@@ -0,0 +1,106 @@
1
+
2
+ = Minion: super simple job queue over amqp
3
+
4
+ Minion makes processing jobs over AMQP simple and easy.
5
+
6
+ == Setup
7
+
8
+ Minion pulls the AMQP credentials out the environment via AMQP_URL.
9
+
10
+ $ export AMQP_URL="amqp://johndoe:abc123@localhost/my_vhost"
11
+
12
+ Alternativly you can explicitly set it programmatically like this:
13
+
14
+ Minion.amqp_url = "amqp://johndoe:abc123@localhost/my_vhost"
15
+
16
+ If no URL is supplied, Minion defaults to "amqp://guest:guest@localhost/" which
17
+ is the default credentials for Rabbitmq running locally.
18
+
19
+ == Principles
20
+
21
+ Minion treats your jobs with respect. The queues are durable and not
22
+ autodelete. When popping jobs off the queue, they will not receive an ack
23
+ until the job is done. You can rest assured that once queued, the job will not
24
+ be lost.
25
+
26
+ Sends are done synchronously and receives are done asynchronously. This allows
27
+ you to Minion.enqueue() from the console, or in a mongrel and you don't need to
28
+ worry about eventmachine. It also means that when enqueue returns, the AMQP
29
+ server has received your message. Daemons set to receive messages however use
30
+ eventmachine.
31
+
32
+ Message processing is done one at a time (prefetch 1). If you want tasks done
33
+ in parallel, run two minions.
34
+
35
+ == Push a job onto the queue
36
+
37
+ Its easy to push a job onto the queue.
38
+
39
+ Minion.enqueue("make.sandwich", { "for" => "me", "with" => "bread" })
40
+
41
+ Minion expects a queue name (and will create it if needed). The second argument
42
+ needs to be a hash.
43
+
44
+ == Processing a job
45
+
46
+ require 'minion'
47
+
48
+ include Minion
49
+
50
+ job "make.sandwich" do |args|
51
+ Sandwich.make(args["for"],args["with"])
52
+ end
53
+
54
+ == Chaining multiple steps
55
+
56
+ If you have a task that requires more than one step just pass an array of
57
+ queues when you enqueue.
58
+
59
+ Minion.enqueue([ "make.sandwich", "eat.sandwich" ], "for" => "me")
60
+
61
+ job "make.sandwich" do
62
+ ## this return value is merged with for => me and sent to the next queue
63
+ { "type" => "ham on rye" }
64
+ end
65
+
66
+ job "eat.sandwich" do |args|
67
+ puts "I have #{args["type"]} sandwich for #{args["me"]}"
68
+ end
69
+
70
+ == Conditional Processing
71
+
72
+ If you want a minion worker to only subscribe to a queue under specific
73
+ conditions there is a :when parameter that takes a lambda as an argument. For
74
+ example, if you had a queue that makes sandwiches but only if there is bread
75
+ on hand, it would be.
76
+
77
+ job "make.sandwich", :when => lambda { not Bread.out? } do
78
+ Sandwich.make
79
+ end
80
+
81
+ == Error handling
82
+
83
+ When an error is thrown in a job handler, the job is requeued to be done later
84
+ and the minion process exits. If you define an error handler, however, the
85
+ error handler is run and the job is removed from the queue.
86
+
87
+ error do |e|
88
+ puts "got an error! #{e}"
89
+ end
90
+
91
+ == Logging
92
+
93
+ Minion logs to stdout via "puts". You can specify a custom logger like this:
94
+
95
+ logger do |msg|
96
+ puts msg
97
+ end
98
+
99
+ == Meta
100
+
101
+ Created by Orion Henry
102
+
103
+ Patches contributed by Adam Wiggins, Kyle Drake
104
+
105
+ Released under the MIT License: www.opensource.org/licenses/mit-license.php
106
+
data/Rakefile ADDED
@@ -0,0 +1,24 @@
1
+ require 'jeweler'
2
+
3
+ Jeweler::Tasks.new do |s|
4
+ s.name = "minion"
5
+ s.description = "Super simple job queue over AMQP"
6
+ s.summary = s.description
7
+ s.author = "Orion Henry"
8
+ s.email = "orion@heroku.com"
9
+ s.homepage = "http://github.com/orionz/minion"
10
+ s.rubyforge_project = "minion"
11
+ s.files = FileList["[A-Z]*", "{bin,lib,spec}/**/*"]
12
+ s.add_dependency "amqp", ">= 0.6.7"
13
+ s.add_dependency "bunny", ">= 0.6.0"
14
+ s.add_dependency "json", ">= 1.2.0"
15
+ end
16
+
17
+ Jeweler::RubyforgeTasks.new
18
+
19
+ desc 'Run specs'
20
+ task :spec do
21
+ sh 'bacon -s spec/*_spec.rb'
22
+ end
23
+
24
+ task :default => :spec
data/VERSION ADDED
@@ -0,0 +1 @@
1
+ 0.1.15
data/examples/math.rb ADDED
@@ -0,0 +1,36 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ $:.unshift File.dirname(__FILE__) + '/../lib'
4
+ require 'rubygems'
5
+ require 'minion'
6
+
7
+ include Minion
8
+
9
+ error do |exception,queue,message,headers|
10
+ puts "got an error processing queue #{queue}"
11
+ puts exception.message
12
+ puts exception.backtrace
13
+ end
14
+
15
+ logger do |msg|
16
+ puts "--> #{msg}"
17
+ end
18
+
19
+ job "math.incr" do |args|
20
+ { "number" => (1 + args["number"].to_i) }
21
+ end
22
+
23
+ job "math.double" do |args|
24
+ { "number" => (2 * args["number"].to_i) }
25
+ end
26
+
27
+ job "math.square" do |args|
28
+ { "number" => (args["number"].to_i * args["number"].to_i) }
29
+ end
30
+
31
+ job "math.print" do |args|
32
+ puts "NUMBER -----> #{args["number"]}"
33
+ end
34
+
35
+ enqueue([ "math.incr", "math.double", "math.square", "math.incr", "math.double", "math.print" ], { :number => 3 })
36
+
@@ -0,0 +1,32 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ $:.unshift File.dirname(__FILE__) + '/../lib'
4
+ require 'rubygems'
5
+ require 'minion'
6
+
7
+ include Minion
8
+
9
+ error do |exception,queue,message,headers|
10
+ puts "got an error processing queue #{queue}"
11
+ puts exception.message
12
+ puts exception.backtrace
13
+ end
14
+
15
+ job "add.bread" do |args|
16
+ { "bread" => "sourdough" }
17
+ end
18
+
19
+ job "add.meat" do |args|
20
+ { "meat" => "turkey" }
21
+ end
22
+
23
+ job "add.condiments" do |args|
24
+ { "condiments" => "mayo" }
25
+ end
26
+
27
+ job "eat.sandwich" do |args|
28
+ puts "YUM! A #{args['meat']} on #{args['bread']} sandwich with #{args['condiments']}"
29
+ end
30
+
31
+ enqueue(["add.bread", "add.meat", "add.condiments", "eat.sandwich" ])
32
+
data/examples/when.rb ADDED
@@ -0,0 +1,28 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ $:.unshift File.dirname(__FILE__) + '/../lib'
4
+ require 'rubygems'
5
+ require 'minion'
6
+
7
+ include Minion
8
+
9
+ error do |exception,queue,message,headers|
10
+ puts "got an error processing queue #{queue}"
11
+ puts exception.message
12
+ puts exception.backtrace
13
+ end
14
+
15
+ logger do |msg|
16
+ puts "--> #{msg}"
17
+ end
18
+
19
+ $listen = true
20
+
21
+ job "do.once", :when => lambda { $listen } do |args|
22
+ puts "Do this one action - then unsubscribe..."
23
+ $listen = false
24
+ end
25
+
26
+ enqueue("do.once",[])
27
+ enqueue("do.once",[])
28
+
data/lib/minion.rb ADDED
@@ -0,0 +1,214 @@
1
+ require 'uri'
2
+ require 'json' unless defined? ActiveSupport::JSON
3
+ require 'mq'
4
+ require 'bunny'
5
+ require 'minion/handler'
6
+
7
+ module Minion
8
+
9
+ class AMQPServerConnectError < StandardError; end;
10
+
11
+ NUMBER_OF_RETRIES = 3
12
+
13
+ extend self
14
+
15
+ def url=(url)
16
+ @@config_url = url
17
+ end
18
+
19
+ def enqueue(jobs, data = {})
20
+ raise "cannot enqueue a nil job" if jobs.nil?
21
+ raise "cannot enqueue an empty job" if jobs.empty?
22
+
23
+ ## jobs can be one or more jobs
24
+ if jobs.respond_to? :shift
25
+ queue = jobs.shift
26
+ data["next_job"] = jobs unless jobs.empty?
27
+ else
28
+ queue = jobs
29
+ end
30
+
31
+ # NOTE: we encode to yaml, Minion encodes to json. The reason is that json cannot handle Ruby symbols
32
+ yamlized_data = data.to_yaml
33
+ log "send: #{queue}:#{yamlized_data}"
34
+
35
+ # NOTE: When rabbitMQ goes down, the mongrel keeps on holding the stale connection to it. We need to establish a new
36
+ # connection when this happens.
37
+
38
+ with_bunny_connection_retry do
39
+ bunny.queue(queue, :durable => false, :auto_delete => false).publish(yamlized_data)
40
+ end
41
+ end
42
+
43
+ def log(msg)
44
+ @@logger ||= proc { |m| puts "#{Time.now} :minion: #{m}" }
45
+ @@logger.call(msg)
46
+ end
47
+
48
+ def error(&blk)
49
+ @@error_handler = blk
50
+ end
51
+
52
+ def logger(&blk)
53
+ @@logger = blk
54
+ end
55
+
56
+ def job(queue, options = {}, &blk)
57
+ handler = Minion::Handler.new queue
58
+ handler.when = options[:when] if options[:when]
59
+ handler.unsub = lambda do
60
+ log "unsubscribing to #{queue}"
61
+ MQ.queue(queue, :durable => false, :auto_delete => false).unsubscribe
62
+ end
63
+
64
+ handler.sub = lambda do
65
+ log "subscribing to #{queue}"
66
+ MQ.queue(queue, :durable => false, :auto_delete => false).subscribe(:ack => false) do |h,m|
67
+ return if AMQP.closing?
68
+ begin
69
+ log "recv: #{queue}:#{m}"
70
+
71
+ args = decode_json(m)
72
+
73
+ result = yield(args)
74
+
75
+ next_job(args, result)
76
+ rescue Object => e
77
+ raise unless error_handler
78
+ error_handler.call(e,queue,m,h)
79
+ end
80
+ check_all
81
+ end
82
+ end
83
+ @@handlers ||= []
84
+ at_exit { Minion.run } if @@handlers.size == 0
85
+ @@handlers << handler
86
+ end
87
+
88
+ def decode_json(string)
89
+ if defined? ActiveSupport::JSON
90
+ ActiveSupport::JSON.decode string
91
+ else
92
+ JSON.load string
93
+ end
94
+ end
95
+
96
+ def check_all
97
+ @@handlers.each { |h| h.check }
98
+ end
99
+
100
+ def run
101
+ log "Starting minion"
102
+
103
+ Signal.trap('INT') { exit_handler }
104
+ Signal.trap('TERM'){ exit_handler }
105
+
106
+ EM.run do
107
+ connection = AMQP.start(amqp_config) do
108
+ MQ.prefetch(1)
109
+ check_all
110
+ end
111
+
112
+ # NOTE: By default, Minion gets into a wierd state when there is a connection error (i.e., the AMQP server is down).
113
+ # In short, it continues running without any subscriptions. We chose to detect this case, and kill the worker that
114
+ # has invoke run. This will allow it to be restarted by the monitoring system, and hopefully by then, the AMQP
115
+ # server will be back up.
116
+ if connection.error?
117
+ exit_handler
118
+ raise AMQPServerConnectError.new("Couldn't connect to RabbitMQ server at #{amqp_url}")
119
+ end
120
+ end
121
+ end
122
+
123
+ def amqp_url
124
+ @@amqp_url ||= ENV["AMQP_URL"] || "amqp://guest:guest@localhost/"
125
+ end
126
+
127
+ def amqp_url=(url)
128
+ clear_bunny
129
+ @@amqp_url = url
130
+ end
131
+
132
+ def delete!(*queue_names)
133
+ EM.run do
134
+ AMQP.start(amqp_config) do
135
+ queue_names.each do |queue_name|
136
+ MQ.queue(queue_name).delete
137
+ msg = "Minion: deleting '#{queue_name}' queue from the AMQP server."
138
+ ActiveRecord::Base::logger.info(msg)
139
+ log msg
140
+ end
141
+ end
142
+
143
+ AMQP.stop { EM.stop }
144
+ end
145
+
146
+ end
147
+
148
+ private
149
+
150
+ def amqp_config
151
+ uri = URI.parse(amqp_url)
152
+ {
153
+ :vhost => uri.path,
154
+ :host => uri.host,
155
+ :user => uri.user,
156
+ :port => (uri.port || 5672),
157
+ :pass => uri.password
158
+ }
159
+ rescue Object => e
160
+ raise "invalid AMQP_URL: #{uri.inspect} (#{e})"
161
+ end
162
+
163
+ def new_bunny
164
+ b = Bunny.new(amqp_config)
165
+ b.start
166
+ b
167
+ end
168
+
169
+ def bunny
170
+ @@bunny ||= new_bunny
171
+ end
172
+
173
+ def next_job(args, response)
174
+ queue = args.delete("next_job")
175
+ enqueue(queue,args.merge(response)) if queue and not queue.empty?
176
+ end
177
+
178
+ def error_handler
179
+ @@error_handler ||= nil
180
+ end
181
+
182
+ def exit_handler
183
+ AMQP.stop { EM.stop }
184
+ end
185
+
186
+ def with_bunny_connection_retry
187
+ num_executions = 1
188
+ begin
189
+ yield
190
+ rescue Bunny::ServerDownError, Bunny::ConnectionError => e
191
+ clear_bunny
192
+ if num_executions < NUMBER_OF_RETRIES
193
+ log "Retry ##{num_executions} : #{caller.first}"
194
+ num_executions += 1
195
+ sleep 0.5
196
+ retry
197
+ else
198
+ error = e.class.new(e.message + " (Retried #{NUMBER_OF_RETRIES} times. Giving up.)")
199
+ error.set_backtrace(e.backtrace)
200
+ raise error
201
+ end
202
+ rescue => e
203
+ clear_bunny
204
+ raise e
205
+ end
206
+ end
207
+
208
+ def clear_bunny
209
+ @@bunny = nil
210
+ end
211
+
212
+
213
+ end
214
+
@@ -0,0 +1,30 @@
1
+ module Minion
2
+ class Handler
3
+ attr_accessor :queue, :sub, :unsub, :when, :on
4
+ def initialize(queue)
5
+ @queue = queue
6
+ @when = lambda { true }
7
+ @sub = lambda {}
8
+ @unsub = lambda {}
9
+ @on = false
10
+ end
11
+
12
+ def should_sub?
13
+ @when.call
14
+ end
15
+
16
+ def check
17
+ if should_sub?
18
+ @sub.call unless @on
19
+ @on = true
20
+ else
21
+ @unsub.call if @on
22
+ @on = false
23
+ end
24
+ end
25
+
26
+ def to_s
27
+ "<handler queue=#{@queue} on=#{@on}>"
28
+ end
29
+ end
30
+ end
data/spec/base.rb ADDED
@@ -0,0 +1,21 @@
1
+ require File.dirname(__FILE__) + '/../lib/minion'
2
+
3
+ require 'bacon'
4
+ require 'mocha/standalone'
5
+ require 'mocha/object'
6
+
7
+ class Bacon::Context
8
+ include Mocha::API
9
+
10
+ def initialize(name, &block)
11
+ @name = name
12
+ @before, @after = [
13
+ [lambda { mocha_setup }],
14
+ [lambda { mocha_verify ; mocha_teardown }]
15
+ ]
16
+ @block = block
17
+ end
18
+
19
+ def xit(desc, &bk)
20
+ end
21
+ end
@@ -0,0 +1,10 @@
1
+ require File.dirname(__FILE__) + '/base'
2
+
3
+ describe Minion do
4
+ it "should throw an exception when passed a nil queue" do
5
+ lambda { Minion.enqueue(nil, {}) }.should.raise(RuntimeError)
6
+ end
7
+ it "should throw an exception when passed an empty queue" do
8
+ lambda { Minion.enqueue([], {}) }.should.raise(RuntimeError)
9
+ end
10
+ end
metadata ADDED
@@ -0,0 +1,129 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: af_minion
3
+ version: !ruby/object:Gem::Version
4
+ hash: 121
5
+ prerelease: false
6
+ segments:
7
+ - 0
8
+ - 1
9
+ - 15
10
+ - 1
11
+ version: 0.1.15.1
12
+ platform: ruby
13
+ authors:
14
+ - Orion Henry
15
+ - AppFolio Dev Team
16
+ autorequire:
17
+ bindir: bin
18
+ cert_chain: []
19
+
20
+ date: 2010-08-24 00:00:00 -07:00
21
+ default_executable:
22
+ dependencies:
23
+ - !ruby/object:Gem::Dependency
24
+ name: amqp
25
+ prerelease: false
26
+ requirement: &id001 !ruby/object:Gem::Requirement
27
+ none: false
28
+ requirements:
29
+ - - ">="
30
+ - !ruby/object:Gem::Version
31
+ hash: 9
32
+ segments:
33
+ - 0
34
+ - 6
35
+ - 7
36
+ version: 0.6.7
37
+ type: :runtime
38
+ version_requirements: *id001
39
+ - !ruby/object:Gem::Dependency
40
+ name: bunny
41
+ prerelease: false
42
+ requirement: &id002 !ruby/object:Gem::Requirement
43
+ none: false
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ hash: 7
48
+ segments:
49
+ - 0
50
+ - 6
51
+ - 0
52
+ version: 0.6.0
53
+ type: :runtime
54
+ version_requirements: *id002
55
+ - !ruby/object:Gem::Dependency
56
+ name: json
57
+ prerelease: false
58
+ requirement: &id003 !ruby/object:Gem::Requirement
59
+ none: false
60
+ requirements:
61
+ - - ">="
62
+ - !ruby/object:Gem::Version
63
+ hash: 31
64
+ segments:
65
+ - 1
66
+ - 2
67
+ - 0
68
+ version: 1.2.0
69
+ type: :runtime
70
+ version_requirements: *id003
71
+ description: Super simple job queue over AMQP with modifications from AppFolio Inc.
72
+ email: tushar.ranka@appfolio.com orion@heroku.com
73
+ executables: []
74
+
75
+ extensions: []
76
+
77
+ extra_rdoc_files:
78
+ - README.rdoc
79
+ files:
80
+ - README.rdoc
81
+ - Rakefile
82
+ - VERSION
83
+ - lib/minion.rb
84
+ - lib/minion/handler.rb
85
+ - spec/base.rb
86
+ - spec/enqueue_spec.rb
87
+ - examples/math.rb
88
+ - examples/sandwich.rb
89
+ - examples/when.rb
90
+ has_rdoc: true
91
+ homepage: http://github.com/orionz/minion
92
+ licenses: []
93
+
94
+ post_install_message:
95
+ rdoc_options:
96
+ - --charset=UTF-8
97
+ require_paths:
98
+ - lib
99
+ required_ruby_version: !ruby/object:Gem::Requirement
100
+ none: false
101
+ requirements:
102
+ - - ">="
103
+ - !ruby/object:Gem::Version
104
+ hash: 3
105
+ segments:
106
+ - 0
107
+ version: "0"
108
+ required_rubygems_version: !ruby/object:Gem::Requirement
109
+ none: false
110
+ requirements:
111
+ - - ">="
112
+ - !ruby/object:Gem::Version
113
+ hash: 3
114
+ segments:
115
+ - 0
116
+ version: "0"
117
+ requirements: []
118
+
119
+ rubyforge_project: af_minion
120
+ rubygems_version: 1.3.7
121
+ signing_key:
122
+ specification_version: 3
123
+ summary: Super simple job queue over AMQP
124
+ test_files:
125
+ - spec/base.rb
126
+ - spec/enqueue_spec.rb
127
+ - examples/math.rb
128
+ - examples/sandwich.rb
129
+ - examples/when.rb