qup 1.2.0 → 1.4.0
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.
- 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
|