klomp 0.0.8 → 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
|