mb-minion 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,177 @@
1
+ require 'pp'
2
+ # encoding: utf-8
3
+ module Minion
4
+ class Handler
5
+ attr_reader :queue_name, :block, :batch_size, :wait
6
+
7
+ # Executes the handler. Will subscribe to a queue or unsubscribe to it
8
+ # depending on the conditions.
9
+ #
10
+ # @example Execute the handler.
11
+ # handler.execute
12
+ def execute
13
+ subscribable? ? subscribe : unsubscribe
14
+ end
15
+
16
+ # Instantiate the new handler. Takes a queue name and optional lambda to
17
+ # determine conditionally if a queue is subscribable.
18
+ #
19
+ # @example Create the new handler.
20
+ # Handler.new("minion.test")
21
+ #
22
+ # @param [ String ] queue_name The name of the queue.
23
+ # @param [ Hash ]
24
+ # @option options [ lambda ] :when The block for conditionally subscribing.
25
+ # @option options [ fixnum ] :batch_size The number of elements per batch
26
+ # @option options [ symbol ] :map The type of map operation: fanout or reduce
27
+ def initialize(queue_name, block, options = {})
28
+ @queue_name, @block = queue_name, block
29
+ @subscribable = options[:when]
30
+ @batch_size = options[:batch_size]
31
+ @wait = options[:wait] || false
32
+ raise ArgumentError, "wait parameter makes no sense without a batch_size" if (@wait && ! @batch_size)
33
+ end
34
+
35
+ private
36
+
37
+ # Returns true if the handler is already subscribed to the queue.
38
+ #
39
+ # @example Is the handler running?
40
+ # handler.running?
41
+ #
42
+ # @return [ true, false ] Is the handler running?
43
+ def running?
44
+ !!@running
45
+ end
46
+
47
+ # Determines if the queue is able to be subscribed to.
48
+ #
49
+ # @example Is the queue subscribable?
50
+ # handler.subscribable?
51
+ def subscribable?
52
+ @subscribable ? @subscribable.call : true
53
+ end
54
+
55
+ # Subscribe to the queue. Will do so if the handler is not already
56
+ # subscribed.
57
+ #
58
+ # @example Subscribe to the queue.
59
+ # handler.subscribe
60
+ def subscribe
61
+ unless running?
62
+ Minion.info("Subscribing to #{queue_name}")
63
+ chan = AMQP::Channel.new
64
+ chan.prefetch(1)
65
+ queue = chan.queue(queue_name, :durable => true, :auto_delete => false)
66
+ if batch_size && batch_size > 1
67
+ process_batch(queue)
68
+ else
69
+ process_single_message(queue)
70
+ end
71
+ @running = true
72
+ end
73
+
74
+ end
75
+
76
+ # Process a multiple messages from a queue as a batch
77
+ #
78
+ # @example Subscribe to the queue.
79
+ # handler.process_batch(queue)
80
+ #
81
+ # @param [ AMQP::Queue ]
82
+ #
83
+ def process_batch(queue)
84
+ # Our batch message will have an array for it's content
85
+ msg = Message.new
86
+ queue.subscribe(:ack => true) do |h, m|
87
+ return if AMQP.closing?
88
+ Minion.info("Received: #{queue_name}:#{m}, #{h}")
89
+ args = decode(m)
90
+
91
+ # All messages in the batch get the callbacks from
92
+ # the first message. This is why when using chained
93
+ # callbacks on batches, you always have to use the
94
+ # same combo of callback-queues!
95
+ msg.callbacks = args['callbacks']
96
+ msg.batch << args['content']
97
+ h.ack # acks are useless in batch-mode.
98
+ # You'll have to make sure you requeue manually
99
+ if (msg.batch.size == batch_size) || process_anyway?
100
+ msg.content = block.call(msg)
101
+ msg.callback
102
+ msg.batch.clear
103
+ end
104
+ Minion.execute_handlers
105
+ end
106
+ rescue Object => e
107
+ Minion.alert(e)
108
+ end
109
+
110
+ # Process a single message from a queue
111
+ #
112
+ # @example Subscribe to the queue.
113
+ # handler.process_single_message(queue)
114
+ #
115
+ # @param [ AMQP::Queue ]
116
+ #
117
+ def process_single_message(queue)
118
+ queue.subscribe(:ack => true) do |h, m|
119
+ return if AMQP.closing?
120
+ Minion.info("Received: #{queue_name}:#{m}, #{h}")
121
+ msg = Message.new(m, h)
122
+ msg.content = block.call(msg)
123
+ h.ack
124
+ msg.callback
125
+ Minion.execute_handlers
126
+ end
127
+ rescue Object => e
128
+ Minion.alert(e)
129
+ end
130
+
131
+ # Get a string respresentation of the handler.
132
+ #
133
+ # @example Print out the string.
134
+ # handler.to_s
135
+ #
136
+ # @return [ String ] The handler as a string.
137
+ def to_s
138
+ "<handler queue_name=#{@queue_name} on=#{@on}>"
139
+ end
140
+
141
+ # Unsubscribe from the queue.
142
+ #
143
+ # @example Unsubscribe from the queue.
144
+ # handler.unsubscribe
145
+ def unsubscribe
146
+ Minion.info("Unsubscribing to #{queue_name}")
147
+ AMQP::Channel.new.queue(queue_name, :durable => true, :auto_delete => false).unsubscribe
148
+ @running = false
149
+ end
150
+
151
+ private
152
+
153
+ def decode(json)
154
+ defined?(ActiveSupport::JSON) ?
155
+ ActiveSupport::JSON.decode(json) : JSON.load(json)
156
+ end
157
+
158
+ # Determine if we should process a batch even if
159
+ # we haven't reached the batch_size
160
+ #
161
+ # @return [ Boolean ] if we should go ahead and process the batch
162
+ def process_anyway?
163
+ return false if Minion.message_count(queue_name) != 0 # there's work to be done!
164
+ case wait
165
+ when true then false # Wait indefinitely
166
+ when false then true # Don't wait at all
167
+ when Numeric
168
+ (0..wait).each do |i|
169
+ return false if Minion.message_count(queue_name) != 0
170
+ sleep 1
171
+ end
172
+ # Wait this many, then if the queue is still empty, go ahead
173
+ Minion.message_count(queue_name) == 0
174
+ end
175
+ end
176
+ end
177
+ end
@@ -0,0 +1,58 @@
1
+ require File.join 'active_support', 'core_ext', 'module', 'delegation'
2
+
3
+ module Minion
4
+ class Message
5
+ attr_accessor :content, :callbacks, :headers, :batch
6
+ delegate :clear, :map, :each, :size, :count, :[], :each_with_index, :cycle, :shuffle, :to => :content
7
+
8
+ def initialize json="{}", header=nil
9
+ data = decode(json)
10
+ @headers = [header]
11
+ @callbacks = data['callbacks']
12
+ @content = data['content']
13
+ @batch = data['batch'] || []
14
+ end
15
+
16
+ def << data
17
+ @content << data
18
+ end
19
+
20
+ # Enqueue a job for the next callback in the chain
21
+ #
22
+ # @return void
23
+ def callback
24
+ headers.clear
25
+ if callbacks and not callbacks.empty?
26
+ queue_name = callbacks.shift
27
+ Minion.enqueue(queue_name, as_json)
28
+ end
29
+ end
30
+
31
+ private
32
+
33
+ # Decode the json string into a hash.
34
+ #
35
+ # @example Decode the json.
36
+ # decode("{ field : "value" }")
37
+ #
38
+ # @param [ String ] json The json string.
39
+ #
40
+ # @return [ Hash ] The json as a hash.
41
+ def decode(json)
42
+ defined?(ActiveSupport::JSON) ?
43
+ ActiveSupport::JSON.decode(json) : JSON.load(json)
44
+ end
45
+
46
+ def as_json
47
+ { 'callbacks' => callbacks,
48
+ 'headers' => headers,
49
+ 'content' => content
50
+ }
51
+ end
52
+
53
+ def to_json
54
+ JSON.dump(as_json || {}).force_encoding("ISO-8859-1")
55
+ end
56
+
57
+ end
58
+ end
@@ -0,0 +1,4 @@
1
+ # encoding: utf-8
2
+ module Minion #:nodoc
3
+ VERSION = "0.2.0"
4
+ end
@@ -0,0 +1,147 @@
1
+ require "spec_helper"
2
+
3
+ describe Minion::Handler do
4
+
5
+ before(:all) do
6
+ Minion.logger {}
7
+ end
8
+
9
+ let(:channel) do
10
+ stub.quacks_like(AMQP::Channel.allocate)
11
+ end
12
+
13
+ let(:queue) do
14
+ stub.quacks_like(AMQP::Queue.allocate)
15
+ end
16
+
17
+ describe "#execute" do
18
+
19
+ before do
20
+ channel.expects(:prefetch).at_most_once
21
+ AMQP::Channel.stubs(:new).returns(channel)
22
+ end
23
+
24
+ context "when the queue is subscribable" do
25
+
26
+ let(:handler) do
27
+ described_class.new("minion.test", lambda{ true })
28
+ end
29
+
30
+ context "when the handler is not already running" do
31
+
32
+ before do
33
+ channel.expects(:queue).with(
34
+ "minion.test", :durable => true, :auto_delete => false
35
+ ).returns(queue)
36
+ end
37
+
38
+ it "subscribes to the queue" do
39
+ queue.expects(:subscribe)
40
+ handler.execute
41
+ end
42
+ end
43
+
44
+ context "when the handler is running" do
45
+
46
+ before do
47
+ handler.instance_variable_set(:@running, true)
48
+ end
49
+
50
+ it "does not subscribe again" do
51
+ channel.expects(:queue).never
52
+ handler.execute
53
+ end
54
+ end
55
+ end
56
+
57
+ context "when the queue is not subscribable" do
58
+
59
+ let(:handler) do
60
+ described_class.new("minion.test", lambda{ true }, :when => lambda{ false })
61
+ end
62
+
63
+ before do
64
+ channel.expects(:queue).with(
65
+ "minion.test", :durable => true, :auto_delete => false
66
+ ).returns(queue)
67
+ end
68
+
69
+ it "unsubscribes from the queue" do
70
+ queue.expects(:unsubscribe)
71
+ handler.execute
72
+ end
73
+ end
74
+
75
+ context "when wait parameter is specified" do
76
+ it "should raise an error" do
77
+ expect do
78
+ described_class.new("minion.test", lambda{ |batch| {"content" => true} }, :wait => true)
79
+ end.to raise_error(ArgumentError)
80
+ end
81
+ end
82
+
83
+ context "when a batch size is specified" do
84
+ let(:block) do
85
+ lambda{ |batch| {"content" => true} }
86
+ end
87
+
88
+ let(:handler) do
89
+ described_class.new("minion.test", block, :batch_size => 10)
90
+ end
91
+
92
+ let(:header) do
93
+ stub.quacks_like(AMQP::Header.allocate)
94
+ end
95
+
96
+ let(:serialized) do
97
+ '{"content":{"field":"value"}}'
98
+ end
99
+
100
+ let(:batch) do
101
+ [header, serialized] * 10
102
+ end
103
+
104
+ before do
105
+ queue.expects(:subscribe).multiple_yields(batch)
106
+ channel.expects(:queue).with(
107
+ "minion.test", :durable => true, :auto_delete => false
108
+ ).returns(queue)
109
+ Minion.expects(:execute_handlers)
110
+ header.expects(:ack)
111
+ end
112
+
113
+ it "calls once for 10 messages" do
114
+ block.expects(:call).once
115
+ handler.execute
116
+ end
117
+
118
+ context "when wait parameter is specified" do
119
+ let(:handler) do
120
+ described_class.new("minion.test", block, :batch_size => 10, :wait => true)
121
+ end
122
+
123
+ it "doesn't call for 9 messages" do
124
+ block.expects(:call).never
125
+ handler.execute
126
+ end
127
+ end
128
+
129
+ context "when wait paramter is numeric" do
130
+ let(:batch) do
131
+ [header, serialized] * 9
132
+ end
133
+
134
+ let(:handler) do
135
+ described_class.new("minion.test", block, :batch_size => 10, :wait => 2)
136
+ end
137
+
138
+ it "calls once for 9 messages after waiting a bit" do
139
+ block.expects(:call).once
140
+ handler.execute
141
+ end
142
+ end
143
+ end
144
+
145
+
146
+ end
147
+ end
@@ -0,0 +1,38 @@
1
+ require "spec_helper"
2
+
3
+ describe Minion::Message do
4
+ let(:header) do
5
+ stub.quacks_like(AMQP::Header.allocate)
6
+ end
7
+
8
+ let(:serialized) do
9
+ '{"content":{"field":"value"}, "callbacks":["minion.second", "minion.third"]}'
10
+ end
11
+
12
+ subject do
13
+ Minion::Message.new(serialized, header)
14
+ end
15
+
16
+ its(:content){ should eql({"field"=>"value"}) }
17
+ its(:callbacks){ should eql ["minion.second", "minion.third"] }
18
+ its(:headers){ should eql [header]}
19
+
20
+ context "when callback is executed" do
21
+
22
+ let(:data) do
23
+ {'callbacks' => ['minion.third'], 'headers' => [], 'content' => {'field' => 'value'}}
24
+ end
25
+
26
+ before do
27
+ subject.headers.clear
28
+ subject.headers.expects(:clear)
29
+ end
30
+
31
+ it "should enqueue the next job" do
32
+ Minion.expects(:enqueue).with('minion.second', data)
33
+ subject.callback
34
+ end
35
+
36
+ end
37
+
38
+ end
@@ -0,0 +1,238 @@
1
+ # encoding: utf-8
2
+ require "spec_helper"
3
+
4
+ describe Minion do
5
+
6
+ let(:bunny) do
7
+ Bunny.new(Minion.config).tap do |bunny|
8
+ bunny.start
9
+ end
10
+ end
11
+
12
+ before(:all) do
13
+ Minion.logger {}
14
+ end
15
+
16
+ describe ".alert" do
17
+
18
+ context "when an error handler is provided" do
19
+
20
+ let(:error) do
21
+ RuntimeError.new("testing")
22
+ end
23
+
24
+ before do
25
+ Minion.error do |error|
26
+ error.message
27
+ end
28
+ end
29
+
30
+ after do
31
+ Minion.error
32
+ end
33
+
34
+ it "delegates to the handler" do
35
+ Minion.alert(error).should == "testing"
36
+ end
37
+ end
38
+
39
+ context "when an error handler is not provided" do
40
+
41
+ let(:error) do
42
+ RuntimeError.new("testing")
43
+ end
44
+
45
+ it "raises the error" do
46
+ expect { Minion.alert(error) }.to raise_error(RuntimeError)
47
+ end
48
+ end
49
+ end
50
+
51
+ describe ".enqueue" do
52
+
53
+ let(:queue) do
54
+ bunny.queue("minion.test", :durable => true, :auto_delete => false)
55
+ end
56
+
57
+ before do
58
+ queue.purge
59
+ end
60
+
61
+ context "when provided a string" do
62
+
63
+ context "when no data is provided" do
64
+
65
+ before do
66
+ Minion.enqueue("minion.test")
67
+ end
68
+
69
+ let(:message) do
70
+ JSON.parse(queue.pop[:payload])
71
+ end
72
+
73
+ it "adds empty json to the queue" do
74
+ message.should == {"content" => {}}
75
+ end
76
+ end
77
+
78
+ context "when nil data is provided" do
79
+
80
+ before do
81
+ Minion.enqueue("minion.test", nil)
82
+ end
83
+
84
+ let(:message) do
85
+ JSON.parse(queue.pop[:payload])
86
+ end
87
+
88
+ it "adds empty json to the queue" do
89
+ message.should == {"content" => nil}
90
+ end
91
+ end
92
+
93
+ context "when data is provided" do
94
+
95
+ context "when the data has no special characters" do
96
+
97
+ let(:data) do
98
+ {"content"=>{"field"=>"value"}}
99
+ end
100
+
101
+ before do
102
+ Minion.enqueue("minion.test", data)
103
+ end
104
+
105
+ let(:message) do
106
+ JSON.parse(queue.pop[:payload])
107
+ end
108
+
109
+ it "adds the json to the queue" do
110
+ message.should == data
111
+ end
112
+ end
113
+
114
+ context "when the data contains special characters" do
115
+
116
+ let(:data) do
117
+ {"content"=>{"field"=>"öüäßÖÜÄ"}}
118
+ end
119
+
120
+ before do
121
+ Minion.enqueue("minion.test", data)
122
+ end
123
+
124
+ let(:message) do
125
+ JSON.parse(queue.pop[:payload])
126
+ end
127
+
128
+ it "adds the json to the queue" do
129
+ message.should == data
130
+ end
131
+ end
132
+ end
133
+ end
134
+
135
+ context "when provided a nil queue" do
136
+
137
+ it "raises an error" do
138
+ expect { Minion.enqueue(nil, {}) }.to raise_error(RuntimeError)
139
+ end
140
+ end
141
+
142
+ context "when passed an array" do
143
+
144
+ let(:first) do
145
+ bunny.queue("minion.first", :durable => true, :auto_delete => false)
146
+ end
147
+
148
+ before do
149
+ first.purge
150
+ end
151
+
152
+ context "when the array is empty" do
153
+
154
+ it "raises an error" do
155
+ expect { Minion.enqueue([], {}) }.to raise_error(RuntimeError)
156
+ end
157
+ end
158
+
159
+ context "when the array has queue names" do
160
+
161
+ let(:data) do
162
+ {"field"=>"value"}
163
+ end
164
+
165
+ let(:serialized) do
166
+ {"content"=>{"field"=>"value"}, "callbacks"=>["minion.second", "minion.third"]}
167
+ end
168
+
169
+ before do
170
+ Minion.enqueue([ "minion.first", "minion.second", "minion.third" ], data)
171
+ end
172
+
173
+ let(:message) do
174
+ JSON.parse(first.pop[:payload])
175
+ end
176
+
177
+ it "adds the serialized data to the first queue" do
178
+ message.should == serialized
179
+ end
180
+
181
+ context "when the data has already been serialized" do
182
+ before do
183
+ Minion.enqueue([ "minion.first", "minion.second", "minion.third" ], serialized)
184
+ end
185
+
186
+ let(:message) do
187
+ JSON.parse(first.pop[:payload])
188
+ end
189
+
190
+ it "adds has the same serialized data in the first queue" do
191
+ message.should == serialized
192
+ end
193
+ end
194
+ end
195
+ end
196
+ end
197
+
198
+
199
+ describe ".error_handling" do
200
+
201
+ context "when nothing has been defined" do
202
+
203
+ it "returns nil" do
204
+ Minion.send(:error_handling).should be_nil
205
+ end
206
+ end
207
+ end
208
+
209
+ describe ".error" do
210
+
211
+ let(:block) do
212
+ lambda{ "testing" }
213
+ end
214
+
215
+ before do
216
+ Minion.error(&block)
217
+ end
218
+
219
+ it "sets the error handling to the provided block" do
220
+ Minion.send(:error_handling).should == block
221
+ end
222
+ end
223
+
224
+ describe ".info" do
225
+
226
+ let(:block) do
227
+ lambda{ |message| message }
228
+ end
229
+
230
+ before do
231
+ Minion.logger(&block)
232
+ end
233
+
234
+ it "delegates the logging to the provided block" do
235
+ Minion.info("testing").should == "testing"
236
+ end
237
+ end
238
+ end