klomp 0.0.8 → 1.0.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/.gemtest +0 -0
- data/.rspec +2 -0
- data/.simplecov +8 -0
- data/ChangeLog.md +11 -1
- data/Gemfile +18 -12
- data/Gemfile.lock +36 -16
- data/Manifest.txt +31 -0
- data/README.md +134 -116
- data/Rakefile +18 -5
- data/klomp.gemspec +52 -17
- data/lib/klomp.rb +44 -3
- data/lib/klomp/connection.rb +163 -0
- data/lib/klomp/frames.rb +108 -0
- data/lib/klomp/sentinel.rb +21 -0
- data/spec/acceptance/acceptance_spec.rb +152 -0
- data/spec/frames/auth_error.txt +0 -0
- data/spec/frames/connect.txt +0 -0
- data/spec/frames/connect_vhost.txt +0 -0
- data/spec/frames/connected.txt +0 -0
- data/spec/frames/disconnect.txt +0 -0
- data/spec/frames/error.txt +0 -0
- data/spec/frames/greeting.txt +0 -0
- data/spec/frames/message.txt +0 -0
- data/spec/frames/receipt.txt +0 -0
- data/spec/frames/subscribe.txt +0 -0
- data/spec/frames/unsubscribe.txt +0 -0
- data/spec/klomp/connection_spec.rb +329 -0
- data/spec/klomp/frames_spec.rb +23 -0
- data/spec/klomp/sentinel_spec.rb +57 -0
- data/spec/klomp_spec.rb +167 -0
- data/spec/spec_helper.rb +36 -0
- data/spec/support/have_received.rb +101 -0
- metadata +163 -35
- data/.gitignore +0 -1
- data/.rvmrc +0 -1
- data/Procfile +0 -2
- data/lib/klomp/client.rb +0 -163
- data/tasks/test_failover.rake +0 -35
- data/test/test_client.rb +0 -245
- data/test/test_helper.rb +0 -14
data/lib/klomp.rb
CHANGED
@@ -1,5 +1,46 @@
|
|
1
|
-
|
1
|
+
class Klomp
|
2
|
+
VERSION = '1.0.0'
|
2
3
|
|
3
|
-
|
4
|
-
|
4
|
+
class Error < StandardError; end
|
5
|
+
|
6
|
+
attr_reader :connections
|
7
|
+
|
8
|
+
def initialize(servers, options = {})
|
9
|
+
servers = [servers].flatten
|
10
|
+
raise ArgumentError, "no servers given" if servers.empty?
|
11
|
+
@connections = servers.map {|s| Connection.new(s, options) }
|
12
|
+
end
|
13
|
+
|
14
|
+
def publish(queue, body, headers = {})
|
15
|
+
connections_remaining = connections.dup
|
16
|
+
begin
|
17
|
+
conn = connections_remaining.sample
|
18
|
+
conn.publish(queue, body, headers)
|
19
|
+
rescue
|
20
|
+
connections_remaining.delete conn
|
21
|
+
retry unless connections_remaining.empty?
|
22
|
+
raise
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
def subscribe(queue, subscriber = nil, &block)
|
27
|
+
connections.each {|conn| conn.subscribe(queue, subscriber, &block) }
|
28
|
+
end
|
29
|
+
|
30
|
+
def unsubscribe(queue)
|
31
|
+
connections.each {|conn| conn.unsubscribe(queue) rescue nil }
|
32
|
+
end
|
33
|
+
|
34
|
+
def connected?
|
35
|
+
connections.detect(&:connected?)
|
36
|
+
end
|
37
|
+
|
38
|
+
def disconnect
|
39
|
+
connections.each {|conn| conn.disconnect }
|
40
|
+
@connections = []
|
41
|
+
end
|
5
42
|
end
|
43
|
+
|
44
|
+
require 'klomp/connection'
|
45
|
+
require 'klomp/sentinel'
|
46
|
+
require 'klomp/frames'
|
@@ -0,0 +1,163 @@
|
|
1
|
+
require 'socket'
|
2
|
+
require 'uri'
|
3
|
+
|
4
|
+
class Klomp
|
5
|
+
FRAME_SEP = "\x00" # null character is frame separator
|
6
|
+
class Connection
|
7
|
+
|
8
|
+
attr_reader :options, :subscriptions, :logger
|
9
|
+
|
10
|
+
def initialize(server, options={})
|
11
|
+
@options = options
|
12
|
+
|
13
|
+
if server =~ /^stomp:\/\//
|
14
|
+
uri = URI.parse server
|
15
|
+
host, port = uri.host, uri.port
|
16
|
+
@options['login'] = uri.user if uri.user
|
17
|
+
@options['passcode'] = uri.password if uri.password
|
18
|
+
if uri.query && !uri.query.empty?
|
19
|
+
uri.query.split('&').each {|pair| k, v = pair.split('=', 2); @options[k] = v }
|
20
|
+
end
|
21
|
+
else
|
22
|
+
address = server.split ':'
|
23
|
+
port, host = address.pop.to_i, address.pop
|
24
|
+
@options['host'] ||= address.pop unless address.empty?
|
25
|
+
end
|
26
|
+
|
27
|
+
@options['server'] = [host, port]
|
28
|
+
@options['host'] ||= host
|
29
|
+
@subscriptions = {}
|
30
|
+
@logger = options['logger']
|
31
|
+
connect
|
32
|
+
end
|
33
|
+
|
34
|
+
def publish(queue, body, headers={})
|
35
|
+
write Frames::Send.new(queue, body, headers)
|
36
|
+
end
|
37
|
+
|
38
|
+
def subscribe(queue, subscriber = nil, &block)
|
39
|
+
raise Klomp::Error, "no subscriber provided" unless subscriber || block
|
40
|
+
raise Klomp::Error, "subscriber does not respond to #call" if subscriber && !subscriber.respond_to?(:call)
|
41
|
+
previous = subscriptions[queue]
|
42
|
+
subscriptions[queue] = subscriber || block
|
43
|
+
write Frames::Subscribe.new(queue) unless previous
|
44
|
+
start_subscriber_thread
|
45
|
+
previous
|
46
|
+
end
|
47
|
+
|
48
|
+
def unsubscribe(queue)
|
49
|
+
write Frames::Unsubscribe.new(queue) if subscriptions.delete queue
|
50
|
+
end
|
51
|
+
|
52
|
+
def connected?() @socket end
|
53
|
+
def closed?() @closing && @socket.nil? end
|
54
|
+
|
55
|
+
def disconnect
|
56
|
+
close!
|
57
|
+
stop_subscriber_thread
|
58
|
+
write Frames::Disconnect.new rescue nil
|
59
|
+
@socket.close rescue nil
|
60
|
+
@socket = nil
|
61
|
+
end
|
62
|
+
|
63
|
+
def reconnect
|
64
|
+
return if connected?
|
65
|
+
logger.warn "reconnect server=#{options['server'].join(':')}" if logger
|
66
|
+
connect
|
67
|
+
subs = subscriptions.dup
|
68
|
+
subscriptions.clear
|
69
|
+
subs.each {|queue, subscriber| subscribe(queue, subscriber) }
|
70
|
+
end
|
71
|
+
|
72
|
+
private
|
73
|
+
def connect
|
74
|
+
@socket = TCPSocket.new *options['server']
|
75
|
+
@socket.set_encoding 'UTF-8'
|
76
|
+
write Frames::Connect.new(options)
|
77
|
+
frame = read Frames::Connected, 0.1
|
78
|
+
log_frame frame if logger
|
79
|
+
raise Error, frame.headers['message'] if frame.error?
|
80
|
+
end
|
81
|
+
|
82
|
+
def write(frame)
|
83
|
+
raise Error, "connection closed" if closed?
|
84
|
+
raise Error, "disconnected" unless connected?
|
85
|
+
|
86
|
+
rs, ws, = IO.select(nil, [@socket], nil, 0.1)
|
87
|
+
raise Error, "connection unavailable for write" unless ws && !ws.empty?
|
88
|
+
|
89
|
+
@socket.write frame.to_s
|
90
|
+
log_frame frame if logger
|
91
|
+
rescue Error
|
92
|
+
raise
|
93
|
+
rescue
|
94
|
+
go_offline
|
95
|
+
raise
|
96
|
+
end
|
97
|
+
|
98
|
+
def read(type, timeout = nil)
|
99
|
+
rs, = IO.select([@socket], nil, nil, timeout)
|
100
|
+
raise Error, "connection unavailable for read" unless rs && !rs.empty?
|
101
|
+
type.new @socket.gets(FRAME_SEP)
|
102
|
+
rescue Error
|
103
|
+
raise
|
104
|
+
rescue
|
105
|
+
go_offline
|
106
|
+
raise
|
107
|
+
end
|
108
|
+
|
109
|
+
def log_frame(frame)
|
110
|
+
return unless logger.debug?
|
111
|
+
body = frame.body
|
112
|
+
body = body.lines.first.chomp + '...' if body =~ /\n/
|
113
|
+
logger.debug "frame=#{frame.name} #{frame.headers.map{|k,v| k + '=' + v }.join(' ')} body=#{body}"
|
114
|
+
end
|
115
|
+
|
116
|
+
def log_exception(ex, level = :error)
|
117
|
+
logger.send level, "exception=#{ex.class.name} message=#{ex.message.inspect} backtrace[0]=#{ex.backtrace[0]} backtrace[1]=#{ex.backtrace[1]}"
|
118
|
+
logger.debug "exception=#{ex.class.name} full_backtrace=" + ex.backtrace.join("\n")
|
119
|
+
end
|
120
|
+
|
121
|
+
def close!
|
122
|
+
@closing = true
|
123
|
+
end
|
124
|
+
|
125
|
+
def go_offline
|
126
|
+
if logger
|
127
|
+
msg = "offline server=#{options['server'].join(':')}"
|
128
|
+
msg << " exception=#{$!.class.name} message=#{$!.message.inspect}" if $!
|
129
|
+
logger.warn msg
|
130
|
+
end
|
131
|
+
@socket.close rescue nil
|
132
|
+
@socket = nil
|
133
|
+
Sentinel.new(self)
|
134
|
+
stop_subscriber_thread
|
135
|
+
end
|
136
|
+
|
137
|
+
INTERRUPT = Class.new(Error)
|
138
|
+
|
139
|
+
def start_subscriber_thread
|
140
|
+
@subscriber_thread ||= Thread.new do
|
141
|
+
loop do
|
142
|
+
begin
|
143
|
+
message = read Frames::Message
|
144
|
+
raise Error, message.headers['message'] if message.error?
|
145
|
+
if subscriber = subscriptions[message.headers['destination']]
|
146
|
+
subscriber.call message
|
147
|
+
end
|
148
|
+
rescue INTERRUPT
|
149
|
+
break
|
150
|
+
rescue => e
|
151
|
+
log_exception(e, :warn) if logger
|
152
|
+
end
|
153
|
+
break if @closing
|
154
|
+
end
|
155
|
+
end
|
156
|
+
end
|
157
|
+
|
158
|
+
def stop_subscriber_thread
|
159
|
+
thread, @subscriber_thread = @subscriber_thread, nil
|
160
|
+
thread.raise INTERRUPT, "disconnect" if thread
|
161
|
+
end
|
162
|
+
end
|
163
|
+
end
|
data/lib/klomp/frames.rb
ADDED
@@ -0,0 +1,108 @@
|
|
1
|
+
class Klomp
|
2
|
+
class FrameError < Error; end
|
3
|
+
|
4
|
+
module Frames
|
5
|
+
class Frame
|
6
|
+
def name
|
7
|
+
@name ||= self.class.name.split('::').last.upcase
|
8
|
+
end
|
9
|
+
|
10
|
+
def headers
|
11
|
+
@headers ||= {}
|
12
|
+
end
|
13
|
+
|
14
|
+
def body
|
15
|
+
@body ||= ""
|
16
|
+
end
|
17
|
+
|
18
|
+
def to_s
|
19
|
+
"#{name}\n#{dump_headers}\n#{@body}#{FRAME_SEP}"
|
20
|
+
end
|
21
|
+
|
22
|
+
def dump_headers
|
23
|
+
headers.map do |pair|
|
24
|
+
pair.map {|x| x.gsub("\n","\\n").gsub(":","\\c").gsub("\\", "\\\\") }.join(':')
|
25
|
+
end.join("\n").tap {|s| s << "\n" unless s.empty? }
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
class ServerFrame < Frame
|
30
|
+
def initialize(data)
|
31
|
+
@headers, @body = parse(data)
|
32
|
+
end
|
33
|
+
|
34
|
+
def error?
|
35
|
+
@error
|
36
|
+
end
|
37
|
+
|
38
|
+
private
|
39
|
+
def parse(data)
|
40
|
+
headers, body = data.split("\n\n")
|
41
|
+
[parse_headers(headers), body.chomp(FRAME_SEP)]
|
42
|
+
end
|
43
|
+
|
44
|
+
def parse_headers(data)
|
45
|
+
frame = nil
|
46
|
+
{}.tap do |headers|
|
47
|
+
data.lines.each do |line|
|
48
|
+
next if line == "\n"
|
49
|
+
unless frame
|
50
|
+
frame = line.chomp
|
51
|
+
@error = frame == "ERROR"
|
52
|
+
if !@error && frame != name
|
53
|
+
raise Klomp::FrameError,
|
54
|
+
"unexpected frame #{frame} (expected #{name}):\n#{data}"
|
55
|
+
end
|
56
|
+
next
|
57
|
+
end
|
58
|
+
kv = line.chomp.split(':').map {|x| x.gsub("\\n","\n").gsub("\\c",":").gsub("\\\\", "\\") }
|
59
|
+
headers[kv.first] = kv.last
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
class Connect < Frame
|
66
|
+
def initialize(options)
|
67
|
+
headers['accept-version'] = '1.1'
|
68
|
+
headers['host'] = options['host'] if options['host']
|
69
|
+
headers['heart-beat'] = "0,0"
|
70
|
+
headers['login'] = options['login'] if options['login']
|
71
|
+
headers['passcode'] = options['passcode'] if options['passcode']
|
72
|
+
end
|
73
|
+
end
|
74
|
+
|
75
|
+
class Connected < ServerFrame
|
76
|
+
end
|
77
|
+
|
78
|
+
class Message < ServerFrame
|
79
|
+
end
|
80
|
+
|
81
|
+
class Send < Frame
|
82
|
+
def initialize(queue, body, hdrs)
|
83
|
+
headers['destination'] = queue
|
84
|
+
headers.update(hdrs.reject{|k,v| %w(destination content-length).include? k })
|
85
|
+
headers['content-type'] ||= 'text/plain'
|
86
|
+
headers['content-length'] = body.bytesize.to_s
|
87
|
+
@body = body
|
88
|
+
end
|
89
|
+
end
|
90
|
+
|
91
|
+
class Subscribe < Frame
|
92
|
+
def initialize(queue)
|
93
|
+
headers['id'] = queue
|
94
|
+
headers['destination'] = queue
|
95
|
+
headers['ack'] = 'auto'
|
96
|
+
end
|
97
|
+
end
|
98
|
+
|
99
|
+
class Unsubscribe < Frame
|
100
|
+
def initialize(queue)
|
101
|
+
headers['id'] = queue
|
102
|
+
end
|
103
|
+
end
|
104
|
+
|
105
|
+
class Disconnect < Frame
|
106
|
+
end
|
107
|
+
end
|
108
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
class Klomp
|
2
|
+
class Sentinel
|
3
|
+
def initialize(connection)
|
4
|
+
@connection = connection
|
5
|
+
Thread.new { run } unless @connection.connected?
|
6
|
+
end
|
7
|
+
|
8
|
+
def run
|
9
|
+
fib_state = [0, 1]
|
10
|
+
loop do
|
11
|
+
begin
|
12
|
+
@connection.reconnect
|
13
|
+
break
|
14
|
+
rescue
|
15
|
+
sleep fib_state[1]
|
16
|
+
fib_state = [fib_state[1], fib_state[0]+fib_state[1]]
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
@@ -0,0 +1,152 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
require 'json'
|
3
|
+
require 'open-uri'
|
4
|
+
|
5
|
+
describe "Klomp acceptance", :acceptance => true do
|
6
|
+
|
7
|
+
Given(:server) { "127.0.0.1:61613" }
|
8
|
+
Given(:credentials) { %w(admin password) }
|
9
|
+
Given(:options) { Hash[*%w(login passcode).zip(credentials).flatten] }
|
10
|
+
Given(:clients) { [] }
|
11
|
+
Given(:klomp) { Klomp.new(server, options).tap {|l| clients << l } }
|
12
|
+
|
13
|
+
context "connect" do
|
14
|
+
|
15
|
+
When { klomp }
|
16
|
+
|
17
|
+
Then { klomp.should be_connected }
|
18
|
+
|
19
|
+
end
|
20
|
+
|
21
|
+
context "publish" do
|
22
|
+
|
23
|
+
When { klomp.publish "/queue/greeting", "hello" }
|
24
|
+
|
25
|
+
Then do
|
26
|
+
vhosts = apollo_api_get_json "/broker/virtual-hosts.json"
|
27
|
+
vhost = vhosts['rows'].detect {|row| row['queues'].include?('greeting') }['id']
|
28
|
+
@queue_path = "/broker/virtual-hosts/#{vhost}/queues/greeting.json"
|
29
|
+
queue = apollo_api_get_json @queue_path
|
30
|
+
queue['metrics']['queue_items'].to_i.should > 0
|
31
|
+
end
|
32
|
+
|
33
|
+
after do
|
34
|
+
apollo_api_delete @queue_path
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
context "subscribe" do
|
39
|
+
|
40
|
+
Given(:subscriber) { double("subscriber") }
|
41
|
+
Given { klomp.publish "/queue/greeting", "hello subscriber!" }
|
42
|
+
|
43
|
+
When do
|
44
|
+
subscriber.stub!(:call).and_return {|msg| subscriber.stub!(:message => msg) }
|
45
|
+
klomp.subscribe "/queue/greeting", subscriber
|
46
|
+
sleep 1 # HAX: waiting for message to be pushed back and processed
|
47
|
+
end
|
48
|
+
|
49
|
+
Then do
|
50
|
+
subscriber.should have_received(:call).with(an_instance_of(Klomp::Frames::Message))
|
51
|
+
subscriber.message.body.should == "hello subscriber!"
|
52
|
+
end
|
53
|
+
|
54
|
+
|
55
|
+
context "and unsubscribe" do
|
56
|
+
|
57
|
+
When do
|
58
|
+
subscriber.reset
|
59
|
+
klomp.unsubscribe "/queue/greeting"
|
60
|
+
klomp.publish "/queue/greeting", "hello subscriber?"
|
61
|
+
sleep 1
|
62
|
+
end
|
63
|
+
|
64
|
+
Then do
|
65
|
+
subscriber.should_not have_received(:call)
|
66
|
+
end
|
67
|
+
|
68
|
+
end
|
69
|
+
|
70
|
+
end
|
71
|
+
|
72
|
+
context "throughput test", :performance => true do
|
73
|
+
|
74
|
+
require 'benchmark'
|
75
|
+
|
76
|
+
Given(:num_threads) { (ENV['THREADS'] || 4).to_i }
|
77
|
+
Given(:msgs_per_thread) { (ENV['MSGS'] || 10000).to_i }
|
78
|
+
Given(:total) { num_threads * msgs_per_thread }
|
79
|
+
|
80
|
+
Given do
|
81
|
+
trap("QUIT") do
|
82
|
+
Thread.list.each do |t|
|
83
|
+
$stderr.puts
|
84
|
+
$stderr.puts t.inspect
|
85
|
+
$stderr.puts t.backtrace.join("\n ")
|
86
|
+
end
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
Given { klomp }
|
91
|
+
|
92
|
+
Then do
|
93
|
+
Thread.abort_on_exception = true
|
94
|
+
|
95
|
+
roundtrip_time = Benchmark.realtime do
|
96
|
+
|
97
|
+
Thread.new do
|
98
|
+
publish_time = Benchmark.realtime do
|
99
|
+
threads = []
|
100
|
+
1.upto(num_threads) do |i|
|
101
|
+
threads << Thread.new do
|
102
|
+
1.upto(msgs_per_thread) do |j|
|
103
|
+
id = i * j
|
104
|
+
print "." if id % 100 == 0
|
105
|
+
klomp.publish "/queue/greeting", "hello #{id}!", "id" => "greeting-#{id}"
|
106
|
+
end
|
107
|
+
end
|
108
|
+
end
|
109
|
+
threads.each(&:join)
|
110
|
+
end
|
111
|
+
|
112
|
+
puts "\n--------------------------------------------------------------------------------\n" \
|
113
|
+
"Sending #{total} messages took #{publish_time} using #{num_threads} threads\n" \
|
114
|
+
"--------------------------------------------------------------------------------\n"
|
115
|
+
end
|
116
|
+
|
117
|
+
ids = []
|
118
|
+
subscribe_time = Benchmark.realtime do
|
119
|
+
klomp.subscribe "/queue/greeting" do |msg|
|
120
|
+
id = msg.headers['id'][/(\d+)/, 1].to_i
|
121
|
+
print "," if id % 100 == 0
|
122
|
+
ids << id
|
123
|
+
end
|
124
|
+
|
125
|
+
Thread.pass until ids.length == total
|
126
|
+
end
|
127
|
+
|
128
|
+
puts "\n--------------------------------------------------------------------------------\n" \
|
129
|
+
"Receiving #{total} messages took #{subscribe_time}\n" \
|
130
|
+
"--------------------------------------------------------------------------------\n"
|
131
|
+
end
|
132
|
+
puts "\n--------------------------------------------------------------------------------\n" \
|
133
|
+
"Roundtrip to process #{total} messages: #{roundtrip_time} (#{total/roundtrip_time} msgs/sec)\n" \
|
134
|
+
"--------------------------------------------------------------------------------\n"
|
135
|
+
end
|
136
|
+
end
|
137
|
+
|
138
|
+
after { clients.each(&:disconnect) }
|
139
|
+
|
140
|
+
def apollo_mgmt_url(path)
|
141
|
+
"http://localhost:61680#{path}"
|
142
|
+
end
|
143
|
+
|
144
|
+
def apollo_api_get_json(path)
|
145
|
+
open(apollo_mgmt_url(path), :http_basic_authentication => credentials) {|f| JSON::parse(f.read) }
|
146
|
+
end
|
147
|
+
|
148
|
+
def apollo_api_delete(path)
|
149
|
+
`curl -s -f -X DELETE -u #{credentials.join(':').inspect} #{apollo_mgmt_url path}`
|
150
|
+
$?.should be_success
|
151
|
+
end
|
152
|
+
end
|