klomp 0.0.8 → 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -1,5 +1,46 @@
1
- require 'klomp/client'
1
+ class Klomp
2
+ VERSION = '1.0.0'
2
3
 
3
- module Klomp
4
- VERSION = '0.0.8'
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
@@ -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