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.
@@ -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