qup 1.2.0 → 1.4.0
Sign up to get free protection for your applications and to get access to all the features.
- data/HISTORY.rdoc +35 -2
- data/Manifest.txt +4 -0
- data/README.rdoc +12 -2
- data/Rakefile +92 -32
- data/lib/qup.rb +4 -2
- data/lib/qup/adapter/kestrel.rb +10 -7
- data/lib/qup/adapter/kestrel/destination.rb +7 -23
- data/lib/qup/adapter/kestrel/queue.rb +20 -15
- data/lib/qup/adapter/kestrel/topic.rb +23 -8
- data/lib/qup/adapter/redis/queue.rb +2 -1
- data/lib/qup/backoff_sleeper.rb +52 -0
- data/lib/qup/batch_consumer.rb +132 -0
- data/lib/qup/consumer.rb +7 -0
- data/lib/qup/session.rb +5 -1
- data/spec/qup/adapter/kestrel_spec.rb +1 -0
- data/spec/qup/adapter/redis/queue_spec.rb +2 -2
- data/spec/qup/backoff_sleeper_sleeper_spec.rb +73 -0
- data/spec/qup/batch_consumer_spec.rb +140 -0
- data/spec/qup/consumer_spec.rb +7 -0
- data/spec/qup/session_spec.rb +7 -0
- data/spec/qup/shared_queue_examples.rb +16 -4
- data/spec/qup/shared_topic_examples.rb +8 -0
- data/spec/spec_helper.rb +8 -1
- metadata +31 -57
@@ -13,8 +13,8 @@ class Qup::Adapter::Kestrel
|
|
13
13
|
# name - the String name of the Topic
|
14
14
|
#
|
15
15
|
# Returns a new Queue
|
16
|
-
def initialize(
|
17
|
-
super(
|
16
|
+
def initialize( client, name )
|
17
|
+
super( client, name )
|
18
18
|
@open_messages = {}
|
19
19
|
end
|
20
20
|
|
@@ -25,7 +25,7 @@ class Qup::Adapter::Kestrel
|
|
25
25
|
#
|
26
26
|
# Returns nothing.
|
27
27
|
def flush
|
28
|
-
@
|
28
|
+
@client.flush(@name)
|
29
29
|
end
|
30
30
|
|
31
31
|
|
@@ -33,8 +33,7 @@ class Qup::Adapter::Kestrel
|
|
33
33
|
#
|
34
34
|
# Returns an integer of the Queue depth
|
35
35
|
def depth
|
36
|
-
|
37
|
-
return stats['items']
|
36
|
+
@client.stats['queues'][@name]['items']
|
38
37
|
end
|
39
38
|
|
40
39
|
|
@@ -42,9 +41,6 @@ class Qup::Adapter::Kestrel
|
|
42
41
|
#
|
43
42
|
# message - the data to put onto the queue.
|
44
43
|
#
|
45
|
-
# The 'message' that is passed in is wrapped in a Qup::Message before being
|
46
|
-
# stored.
|
47
|
-
#
|
48
44
|
# A user of the Qup API should use a Producer instance to put items onto the
|
49
45
|
# queue.
|
50
46
|
#
|
@@ -62,16 +58,16 @@ class Qup::Adapter::Kestrel
|
|
62
58
|
# A user of the Qup API should use a Consumer instance to retrieve items
|
63
59
|
# from the Queue.
|
64
60
|
#
|
65
|
-
# Returns a Message
|
61
|
+
# Returns a Message or nil if no message was on the queue
|
66
62
|
def consume(&block)
|
67
|
-
|
68
|
-
|
69
|
-
|
63
|
+
q_item = @client.reserve( @name )
|
64
|
+
return nil unless q_item
|
65
|
+
q_message = ::Qup::Message.new( q_item.object_id, unmarshal_if_marshalled( q_item ))
|
66
|
+
@open_messages[q_message.key] = q_item
|
70
67
|
if block_given? then
|
71
68
|
yield_message( q_message, &block )
|
72
|
-
else
|
73
|
-
return q_message
|
74
69
|
end
|
70
|
+
return q_message
|
75
71
|
end
|
76
72
|
|
77
73
|
|
@@ -85,13 +81,22 @@ class Qup::Adapter::Kestrel
|
|
85
81
|
def acknowledge( message )
|
86
82
|
open_msg = @open_messages.delete( message.key )
|
87
83
|
raise Qup::Error, "Message #{message.key} is not currently being consumed" unless open_msg
|
88
|
-
@client.
|
84
|
+
@client.close( @name )
|
89
85
|
end
|
90
86
|
|
91
87
|
#######
|
92
88
|
private
|
93
89
|
#######
|
94
90
|
|
91
|
+
def unmarshal_if_marshalled( data )
|
92
|
+
if data[0].ord == 4 and data[1].ord == 8 then
|
93
|
+
Marshal::load( data )
|
94
|
+
else
|
95
|
+
data
|
96
|
+
end
|
97
|
+
end
|
98
|
+
|
99
|
+
|
95
100
|
def yield_message( message, &block )
|
96
101
|
yield message
|
97
102
|
ensure
|
@@ -1,3 +1,5 @@
|
|
1
|
+
require 'json'
|
2
|
+
require 'set'
|
1
3
|
require 'qup/adapter/kestrel/destination'
|
2
4
|
class Qup::Adapter::Kestrel
|
3
5
|
#
|
@@ -27,20 +29,29 @@ class Qup::Adapter::Kestrel
|
|
27
29
|
#
|
28
30
|
# Returns a Subscriber
|
29
31
|
def subscriber( name )
|
30
|
-
::Qup::Subscriber.new( self, subscriber_queue( name )
|
32
|
+
::Qup::Subscriber.new( self, subscriber_queue( name ) )
|
31
33
|
end
|
32
34
|
|
33
35
|
|
34
36
|
# Internal: Return the number of Subscribers to this Topic
|
35
37
|
#
|
38
|
+
# We want the sub portion of the json document that is in the 'counters'
|
39
|
+
# section. The keys in the 'counters' section that represent queue counters
|
40
|
+
# are all prefixed with 'q/<queue_name>/<stat>'. To count the number of
|
41
|
+
# subscribers to this topic, we just count the uniqe <queue_name> elements
|
42
|
+
# that start with this queue's name and followed by a '+'
|
43
|
+
#
|
36
44
|
# Returns integer
|
37
45
|
def subscriber_count
|
38
|
-
c =
|
39
|
-
|
40
|
-
|
41
|
-
|
46
|
+
c = Set.new
|
47
|
+
|
48
|
+
stats['queues'].keys.each do |k|
|
49
|
+
next unless k =~ %r{\A#{@name}\+}
|
50
|
+
parts = k.split("+")
|
51
|
+
c << parts[1]
|
42
52
|
end
|
43
|
-
|
53
|
+
|
54
|
+
return c.size
|
44
55
|
end
|
45
56
|
|
46
57
|
# Internal: Publish a Message to all the Subscribers
|
@@ -49,7 +60,7 @@ class Qup::Adapter::Kestrel
|
|
49
60
|
#
|
50
61
|
# Returns nothing
|
51
62
|
def publish( message )
|
52
|
-
@client.set( @name,
|
63
|
+
@client.set( @name, message ) # do not expire the message
|
53
64
|
end
|
54
65
|
|
55
66
|
#######
|
@@ -58,11 +69,15 @@ class Qup::Adapter::Kestrel
|
|
58
69
|
|
59
70
|
def subscriber_queue( sub_name )
|
60
71
|
sname = subscriber_queue_name( sub_name )
|
61
|
-
::Qup::Adapter::Kestrel::Queue.new( @
|
72
|
+
::Qup::Adapter::Kestrel::Queue.new( @client, sname )
|
62
73
|
end
|
63
74
|
|
64
75
|
def subscriber_queue_name( sub_name )
|
65
76
|
"#{@name}+#{sub_name}"
|
66
77
|
end
|
78
|
+
|
79
|
+
def stats
|
80
|
+
@client.stats
|
81
|
+
end
|
67
82
|
end
|
68
83
|
end
|
@@ -76,7 +76,8 @@ class Qup::Adapter::Redis
|
|
76
76
|
#
|
77
77
|
# Returns a Message
|
78
78
|
def consume(&block)
|
79
|
-
|
79
|
+
data = @client.rpop( name )
|
80
|
+
return if data.nil?
|
80
81
|
message = ::Qup::Message.new( data.object_id, data )
|
81
82
|
@open_messages[message.key] = message
|
82
83
|
if block_given? then
|
@@ -0,0 +1,52 @@
|
|
1
|
+
# Used to sleep for exponentially increasing amounts of time between
|
2
|
+
# successive unsuccessful attempts to do something like poll for queue data or
|
3
|
+
# reconnect to a client. Maxes out at 1 second between attempts.
|
4
|
+
#
|
5
|
+
# Call #tick in every iteration of the task you are trying to accomplish. When
|
6
|
+
# a successful iteration is completed (for example the queue had data, or
|
7
|
+
# connecting was successful) call #reset to let the sleeper know that it can
|
8
|
+
# reset the amount of time between attempts back to 0. The sleeper will not
|
9
|
+
# call Kernel#sleep if it #reset was just called, so it is safe to call #tick
|
10
|
+
# on every iteration, provided that you call #reset on success before calling
|
11
|
+
# #tick.
|
12
|
+
#
|
13
|
+
# Examples:
|
14
|
+
#
|
15
|
+
# sleeper = BackoffSleeper.new
|
16
|
+
#
|
17
|
+
# loop do
|
18
|
+
# message = check_for_message
|
19
|
+
# sleeper.reset unless message.nil?
|
20
|
+
# sleeper.tick
|
21
|
+
# end
|
22
|
+
module Qup
|
23
|
+
class BackoffSleeper
|
24
|
+
attr_reader :count
|
25
|
+
|
26
|
+
MULTIPLIERS = [0, 0.01, 0.1, 1]
|
27
|
+
|
28
|
+
def initialize
|
29
|
+
@count = 0
|
30
|
+
end
|
31
|
+
|
32
|
+
# Register an iteration. Sleeps if neccesary. Does not sleep if #reset has
|
33
|
+
# just been called.
|
34
|
+
def tick
|
35
|
+
Kernel.sleep(length) if length > 0
|
36
|
+
@count += 1
|
37
|
+
end
|
38
|
+
|
39
|
+
# Reset the backoff sequence to 0.
|
40
|
+
def reset
|
41
|
+
@count = 0
|
42
|
+
end
|
43
|
+
|
44
|
+
def length
|
45
|
+
(multiplier + (multiplier * Kernel.rand)) / 2
|
46
|
+
end
|
47
|
+
|
48
|
+
def multiplier
|
49
|
+
MULTIPLIERS[@count] || MULTIPLIERS.last
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
@@ -0,0 +1,132 @@
|
|
1
|
+
# BatchConsumer makes it easy to implement a batch-oriented consumer pattern
|
2
|
+
# by calling the appropriate hooks on the provided "client" class. Clients
|
3
|
+
# have the following callbacks:
|
4
|
+
#
|
5
|
+
# setup - Optional. Called before the client begins receiving messages
|
6
|
+
# (immediately after BatchConsumer#run is called).
|
7
|
+
# process - Required. Called with an instance of Qup::Message when a
|
8
|
+
# message is removed from the queue. After process finishes the message is
|
9
|
+
# acknowledged from the queue. process is not wrapped in any exception
|
10
|
+
# handling, so exceptions raised in process will propagate through the
|
11
|
+
# BatchConsumer and the message will not be acknowledged.
|
12
|
+
# teardown - Optional. Called after the client has received :max_size
|
13
|
+
# messages OR :max_age has been exceeded.
|
14
|
+
#
|
15
|
+
# BatchConsumers should be only be run once. If you need to run again, create
|
16
|
+
# a new instance.
|
17
|
+
#
|
18
|
+
# See #initialize for the configuration options.
|
19
|
+
#
|
20
|
+
# Examples:
|
21
|
+
#
|
22
|
+
# class FileDumper
|
23
|
+
# include Qup::BatchConsumerAPI
|
24
|
+
#
|
25
|
+
# def setup
|
26
|
+
# @file = File.open("/my/file.txt")
|
27
|
+
# end
|
28
|
+
#
|
29
|
+
# def process(message)
|
30
|
+
# @file.puts(message.data)
|
31
|
+
# end
|
32
|
+
#
|
33
|
+
# def teardown
|
34
|
+
# @file.close
|
35
|
+
# end
|
36
|
+
# end
|
37
|
+
#
|
38
|
+
# batch_consumer = Qup::BatchConsumer.new({
|
39
|
+
# :max_size => 5000,
|
40
|
+
# :max_age => 600,
|
41
|
+
# :client => FileDumper.new,
|
42
|
+
# :queue_uri => "maildir:///tmp/test-queue",
|
43
|
+
# :queue_name => "my-queue"
|
44
|
+
# })
|
45
|
+
#
|
46
|
+
# batch_consumer.run # This blocks until there have been 5000 message OR 600 seconds have passed.
|
47
|
+
module Qup
|
48
|
+
module BatchConsumerAPI
|
49
|
+
def setup
|
50
|
+
end
|
51
|
+
|
52
|
+
def process(message)
|
53
|
+
raise NotImplementedError
|
54
|
+
end
|
55
|
+
|
56
|
+
def teardown
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
class BatchConsumer
|
61
|
+
# options - A hash of configuration options.
|
62
|
+
# :client - The object upon which the callbacks are fired. It's class
|
63
|
+
# should include Qup::BatchConsumerAPI for declarative
|
64
|
+
# documentation purposes. Technically, the only constraint
|
65
|
+
# is that this object implement #setup, #process and
|
66
|
+
# #teardown (including Qup::BatchConsumerAPI includes noop
|
67
|
+
# definitions for #setup and #teardown). Required.
|
68
|
+
# :queue_uri - The Qup format queue URI. Required.
|
69
|
+
# :queue_name - The name of the queue that messages will be consumed
|
70
|
+
# from. Required.
|
71
|
+
# :max_size - The maximum number of messages to process. Optional.
|
72
|
+
# :max_age - The maximum number of seconds (from when #run is called)
|
73
|
+
# before finishing. Note that :max_size and :max_age are
|
74
|
+
# ORed, meaning that the BatchConsumer will finish when
|
75
|
+
# the first constraint is met. If neither constraint is
|
76
|
+
# provided, the BatchConsumer will never finish (i.e. #run
|
77
|
+
# will block indefinitely). Optional.
|
78
|
+
def initialize(options = {})
|
79
|
+
@message_count = 0
|
80
|
+
@options = options
|
81
|
+
end
|
82
|
+
|
83
|
+
def run
|
84
|
+
@start = Time.now
|
85
|
+
client.setup
|
86
|
+
|
87
|
+
while live?
|
88
|
+
sleeper.tick
|
89
|
+
consumer.consume do |message|
|
90
|
+
client.process(message)
|
91
|
+
@message_count += 1
|
92
|
+
sleeper.reset
|
93
|
+
end
|
94
|
+
end
|
95
|
+
|
96
|
+
client.teardown
|
97
|
+
end
|
98
|
+
|
99
|
+
def session
|
100
|
+
@session ||= Qup::Session.new(@options[:queue_uri], @options[:session_options] || {})
|
101
|
+
end
|
102
|
+
|
103
|
+
private
|
104
|
+
|
105
|
+
def client
|
106
|
+
@options[:client]
|
107
|
+
end
|
108
|
+
|
109
|
+
def sleeper
|
110
|
+
@sleeper ||= BackoffSleeper.new
|
111
|
+
end
|
112
|
+
|
113
|
+
def consumer
|
114
|
+
@consumer ||= begin
|
115
|
+
session.queue(@options[:queue_name]).consumer
|
116
|
+
end
|
117
|
+
end
|
118
|
+
|
119
|
+
def live?
|
120
|
+
!too_big? && !too_old?
|
121
|
+
end
|
122
|
+
|
123
|
+
def too_big?
|
124
|
+
@message_count >= @options[:max_size] if @options[:max_size]
|
125
|
+
end
|
126
|
+
|
127
|
+
def too_old?
|
128
|
+
(Time.now - @start) > @options[:max_age] if @options[:max_age]
|
129
|
+
end
|
130
|
+
|
131
|
+
end
|
132
|
+
end
|
data/lib/qup/consumer.rb
CHANGED
data/lib/qup/session.rb
CHANGED
@@ -20,6 +20,9 @@ module Qup
|
|
20
20
|
# Public: The URI of this Session
|
21
21
|
attr_reader :uri
|
22
22
|
|
23
|
+
# Internal: access the adapter specific options.
|
24
|
+
attr_reader :options
|
25
|
+
|
23
26
|
# Public: Create a new Session
|
24
27
|
#
|
25
28
|
# uri - The connection String used to connect to appropriate provider
|
@@ -47,9 +50,10 @@ module Qup
|
|
47
50
|
def initialize( uri, options = {} )
|
48
51
|
@uri = URI.parse( uri )
|
49
52
|
@root_path = Pathname.new( @uri.path )
|
53
|
+
@options = options
|
50
54
|
|
51
55
|
adapter_klass = Qup::Adapters[@uri.scheme]
|
52
|
-
@adapter = adapter_klass.new( @uri, options )
|
56
|
+
@adapter = adapter_klass.new( @uri, @options )
|
53
57
|
|
54
58
|
@queues = Hash.new
|
55
59
|
@topics = Hash.new
|
@@ -23,9 +23,9 @@ describe 'Qup::Adapter::Redis::Queue', :redis => true do
|
|
23
23
|
|
24
24
|
describe "#destroy" do
|
25
25
|
it "removes its name from the parent topic's subscriber set" do
|
26
|
-
redis.smembers("parent").should == ["test"]
|
26
|
+
redis.smembers("parent").should be == ["test"]
|
27
27
|
queue.destroy
|
28
|
-
redis.smembers("parent").should == []
|
28
|
+
redis.smembers("parent").should be == []
|
29
29
|
end
|
30
30
|
end
|
31
31
|
|
@@ -0,0 +1,73 @@
|
|
1
|
+
require "spec_helper"
|
2
|
+
|
3
|
+
module Qup
|
4
|
+
describe BackoffSleeper do
|
5
|
+
before { Kernel.stub(:sleep) }
|
6
|
+
|
7
|
+
describe "#length" do
|
8
|
+
it "it returns the multiplier averaged with the multiplier multiplied by rand" do
|
9
|
+
Kernel.stub(:rand).with().and_return(0.5)
|
10
|
+
|
11
|
+
sleeper = BackoffSleeper.new
|
12
|
+
sleeper.stub(:multiplier => 1)
|
13
|
+
|
14
|
+
sleeper.length.should be == ((1 + (1 * 0.5)) / 2)
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
describe "#tick" do
|
19
|
+
it "starts count at 0" do
|
20
|
+
subject.count.should be == 0
|
21
|
+
end
|
22
|
+
|
23
|
+
it "increments count by 1 everytime it's called" do
|
24
|
+
subject.tick
|
25
|
+
subject.count.should be == 1
|
26
|
+
|
27
|
+
subject.tick
|
28
|
+
subject.count.should be == 2
|
29
|
+
end
|
30
|
+
|
31
|
+
it "sleeps for #length if length is > 0" do
|
32
|
+
Kernel.should_receive(:sleep).with(0.123)
|
33
|
+
|
34
|
+
subject.stub(:length => 0.123)
|
35
|
+
subject.tick
|
36
|
+
end
|
37
|
+
|
38
|
+
it "doesn't call sleep if the length is 0" do
|
39
|
+
Kernel.should_not_receive(:sleep).with(0)
|
40
|
+
|
41
|
+
subject.stub(:length => 0)
|
42
|
+
subject.tick
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
describe "#reset" do
|
47
|
+
it "sets count back to 0" do
|
48
|
+
subject.tick
|
49
|
+
expect { subject.reset }.to change { subject.count }.to(0)
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
describe "#multiplier" do
|
54
|
+
it "starts at 0" do
|
55
|
+
subject.multiplier.should be == 0
|
56
|
+
end
|
57
|
+
|
58
|
+
it "backs off exponentially" do
|
59
|
+
multipliers = (0..2).map do
|
60
|
+
subject.tick
|
61
|
+
subject.multiplier
|
62
|
+
end
|
63
|
+
|
64
|
+
multipliers.should be == [0.01, 0.1, 1]
|
65
|
+
end
|
66
|
+
|
67
|
+
it "maxes out at the last value of MULTIPLIERS" do
|
68
|
+
100.times { subject.tick }
|
69
|
+
subject.multiplier.should == BackoffSleeper::MULTIPLIERS.last
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|