amqp-failover 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,83 @@
1
+ # encoding: utf-8
2
+
3
+ module AMQP
4
+ module FailoverClient
5
+ include AMQP::BasicClient
6
+
7
+ attr_accessor :failover
8
+ attr_reader :fallback_monitor
9
+
10
+ attr_accessor :settings
11
+ attr_accessor :on_disconnect
12
+
13
+ def self.extended(base)
14
+ if (base.failover = base.settings.delete(:failover))
15
+ base.on_disconnect = base.method(:disconnected)
16
+ end
17
+ end
18
+
19
+ def failover_switch
20
+ if (new_settings = @failover.from(@settings))
21
+ log_message = "Could not connect to or lost connection to server #{@settings[:host]}:#{@settings[:port]}. " +
22
+ "Attempting connection to: #{new_settings[:host]}:#{new_settings[:port]}"
23
+ logger.error(log_message)
24
+ logger.info(log_message)
25
+
26
+ if @failover.options[:fallback] && @failover.primary == @settings
27
+ fallback(@failover.primary, @failover.fallback_interval)
28
+ end
29
+ @settings = new_settings
30
+ reconnect
31
+ else
32
+ raise Error, "Could not connect to server #{@settings[:host]}:#{@settings[:port]}"
33
+ end
34
+ end
35
+
36
+ def logger
37
+ Failover.logger
38
+ end
39
+
40
+ def configs
41
+ @failover.configs if @failover
42
+ end
43
+
44
+ def clean_exit(msg = nil)
45
+ msg ||= "clean exit"
46
+ logger.info(msg)
47
+ logger.error(msg)
48
+ Process.exit
49
+ end
50
+
51
+ def fallback(conf = {}, retry_interval = nil)
52
+ @fallback_monitor = Failover::ServerDiscovery.monitor(conf, retry_interval) do
53
+ fallback_callback.call(conf, retry_interval)
54
+ end
55
+ end
56
+
57
+ def fallback_callback
58
+ #TODO: Figure out a way to artificially trigger EM to disconnect on fallback without channels being closed.
59
+ @fallback_callback ||= proc { |conf, retry_interval|
60
+ clean_exit("Primary server (#{conf[:host]}:#{conf[:port]}) is back. " +
61
+ "Performing clean exit to be relaunched with primary config.")
62
+ }
63
+ end
64
+ attr_writer :fallback_callback
65
+
66
+ #TODO: Figure out why I originally needed this
67
+ # def process_frame(frame)
68
+ # if mq = channels[frame.channel]
69
+ # mq.process_frame(frame)
70
+ # return
71
+ # end
72
+ #
73
+ # if frame.is_a?(AMQP::Frame::Method) && (method = frame.payload).is_a?(AMQP::Protocol::Connection::Close)
74
+ # if method.reply_text =~ /^NOT_ALLOWED/
75
+ # raise AMQP::Error, "#{method.reply_text} in #{::AMQP::Protocol.classes[method.class_id].methods[method.method_id]}"
76
+ # end
77
+ # end
78
+ # super(frame)
79
+ # end
80
+
81
+ end # FailoverClient
82
+ end # AMQP
83
+
@@ -0,0 +1,59 @@
1
+ # encoding: utf-8
2
+ $LOAD_PATH.unshift File.expand_path(File.dirname(__FILE__))
3
+
4
+ require 'spec_helper'
5
+ require 'mq'
6
+ require 'amqp'
7
+ require 'amqp/server'
8
+ require 'server_helper'
9
+
10
+ describe "Basic AMQP connection with FailoverClient loaded" do
11
+
12
+ after(:each) do
13
+ ServerHelper.clear_logs
14
+ end
15
+
16
+ it "should be using FailoverClient" do
17
+ AMQP.client.should == AMQP::FailoverClient
18
+ end
19
+
20
+ it "should be able to connect" do
21
+ EM.run {
22
+ port = 15672
23
+ timeout = 2
24
+ serv = start_server(port)
25
+ EM.add_timer(1.5) {
26
+ conn = AMQP.connect(:host => 'localhost', :port => 15672)
27
+ EM.add_timer(0.1) {
28
+ conn.should be_connected
29
+ serv.stop
30
+ log = serv.log
31
+ log.size.should == 3
32
+ (0..2).each { |i| log[i]['method'].should == "send" }
33
+ log[0]['class'].should == 'AMQP::Protocol::Connection::Start'
34
+ log[1]['class'].should == 'AMQP::Protocol::Connection::Tune'
35
+ log[2]['class'].should == 'AMQP::Protocol::Connection::OpenOk'
36
+ EM.stop
37
+ }
38
+ }
39
+ }
40
+ end
41
+
42
+ it "should be able to connect and get disconnected" do
43
+ EM.run {
44
+ serv = start_server(25672)
45
+ EM.add_timer(0.1) {
46
+ conn = AMQP.connect(:host => 'localhost', :port => 25672)
47
+ EM.add_timer(0.1) {
48
+ conn.should be_connected
49
+ serv.stop
50
+ EM.add_timer(0.1) {
51
+ conn.should_not be_connected
52
+ EM.stop
53
+ }
54
+ }
55
+ }
56
+ }
57
+ end
58
+
59
+ end
@@ -0,0 +1,141 @@
1
+ # encoding: utf-8
2
+ $LOAD_PATH.unshift File.expand_path(File.dirname(__FILE__))
3
+
4
+ require 'spec_helper'
5
+ require 'amqp/server'
6
+ require 'server_helper'
7
+ require 'logger_helper'
8
+
9
+ describe "Failover support loaded into AMQP gem" do
10
+
11
+ before(:each) do
12
+ @flog = LoggerHelper.new
13
+ AMQP::Failover.logger = @flog
14
+ end
15
+
16
+ after(:each) do
17
+ ServerHelper.clear_logs
18
+ AMQP::Failover.logger = nil
19
+ end
20
+
21
+ it "should be able to connect" do
22
+ port1 = 15672
23
+ EM.run {
24
+ serv = start_server(port1)
25
+ EM.add_timer(0.1) {
26
+ conn = AMQP.connect(:host => 'localhost', :port => port1)
27
+ conn.failover.should be_nil
28
+ EM.add_timer(0.1) {
29
+ conn.should be_connected
30
+ EM.stop
31
+ }
32
+ }
33
+ }
34
+ end
35
+
36
+ it "should be able to connect and failover" do
37
+ port1 = 25672
38
+ port2 = 35672
39
+ EM.run {
40
+ # start mock amqp servers
41
+ serv1 = start_server(port1)
42
+ serv2 = start_server(port2)
43
+ EM.add_timer(0.1) {
44
+ # start amqp client connection and make sure it's picked the right config
45
+ conn = AMQP.connect({:hosts => [{:port => port1}, {:port => port2}]})
46
+ conn.failover.primary[:port].should == port1
47
+ conn.settings[:port].should == port1
48
+ conn.settings.should == conn.failover.primary
49
+ EM.add_timer(0.1) {
50
+ # make sure client connected to the correct server, then kill server
51
+ conn.should be_connected
52
+ serv1.log.should have(3).items
53
+ serv2.log.should have(0).items
54
+ serv1.stop
55
+ EM.add_timer(0.1) {
56
+ # make sure client performed a failover when primary server died
57
+ conn.should be_connected
58
+ [:error, :info].each do |i|
59
+ @flog.send("#{i}_log").should have(1).item
60
+ @flog.send("#{i}_log")[0][0].should match(/connect to or lost connection.+#{port1}.+attempting connection.+#{port2}/i)
61
+ end
62
+ conn.settings[:port].should == port2
63
+ serv1.log.should have(3).items
64
+ serv2.log.should have(3).items
65
+ conn.close
66
+ EM.add_timer(0.1) {
67
+ serv2.stop
68
+ EM.stop
69
+ }
70
+ }
71
+ }
72
+ }
73
+ }
74
+ end
75
+
76
+ it "should be able to fallback when primary server returns" do
77
+ port1 = 45672
78
+ port2 = 55672
79
+ lambda {
80
+ EM.run {
81
+ # start mock amqp servers
82
+ serv1 = start_server(port1)
83
+ serv2 = start_server(port2)
84
+ EM.add_timer(0.1) {
85
+ # start amqp client connection and make sure it's picked the right config
86
+ conn = AMQP.connect({:hosts => [{:port => port1}, {:port => port2}], :fallback => true, :fallback_interval => 0.1})
87
+ conn.failover.primary[:port].should == port1
88
+ conn.settings[:port].should == port1
89
+ conn.settings.should == conn.failover.primary
90
+ EM.add_timer(0.1) {
91
+ # make sure client connected to the correct server, then kill server
92
+ conn.should be_connected
93
+ serv1.log.should have(3).items
94
+ serv2.log.should have(0).items
95
+ serv1.stop
96
+ EM.add_timer(0.1) {
97
+ # make sure client performed a failover when primary server died
98
+ conn.should be_connected
99
+ [:error, :info].each do |i|
100
+ @flog.send("#{i}_log").should have(1).item
101
+ @flog.send("#{i}_log")[0][0].should match(/connect to or lost connection.+#{port1}.+attempting connection.+#{port2}/i)
102
+ end
103
+ conn.settings[:port].should == port2
104
+ serv1.log.should have(3).items
105
+ serv2.log.should have(3).items
106
+ serv3 = start_server(port1)
107
+ EM.add_timer(0.2) {
108
+ # by this point client should have raised a SystemExit exception
109
+ serv2.stop
110
+ EM.stop
111
+ }
112
+ }
113
+ }
114
+ }
115
+ }
116
+ }.should raise_error(SystemExit, "exit")
117
+ [:error, :info].each do |i|
118
+ @flog.send("#{i}_log").should have(2).item
119
+ @flog.send("#{i}_log")[1][0].should match(/primary server.+45672.+performing clean exit/i)
120
+ end
121
+ end
122
+
123
+ it "should abide to :primary_config option" do
124
+ port1 = 75672
125
+ port2 = 65672
126
+ EM.run {
127
+ serv = start_server(port1)
128
+ EM.add_timer(0.1) {
129
+ conn = AMQP.connect({:hosts => [{:port => port1}, {:port => port2}], :primary_config => 1})
130
+ conn.failover.primary[:port].should == port2
131
+ conn.settings[:port].should == port2
132
+ conn.settings.should == conn.failover.primary
133
+ EM.add_timer(0.1) {
134
+ conn.should be_connected
135
+ EM.stop
136
+ }
137
+ }
138
+ }
139
+ end
140
+
141
+ end
@@ -0,0 +1,18 @@
1
+ # encoding: utf-8
2
+
3
+ class LoggerHelper
4
+
5
+ attr_accessor :error_log
6
+ attr_accessor :info_log
7
+
8
+ def info(*args)
9
+ @info_log ||= []
10
+ @info_log << args
11
+ end
12
+
13
+ def error(*args)
14
+ @error_log ||= []
15
+ @error_log << args
16
+ end
17
+
18
+ end
@@ -0,0 +1,77 @@
1
+ # encoding: utf-8
2
+
3
+ require 'rubygems'
4
+ require 'mq'
5
+ require 'amqp'
6
+ require 'amqp/server'
7
+ require 'json'
8
+
9
+ class ServerHelper
10
+
11
+ attr_accessor :stdin
12
+ attr_accessor :stdout
13
+ attr_accessor :stderr
14
+ attr_accessor :pid
15
+
16
+
17
+ def initialize(port = nil, timeout = nil)
18
+ @port = port
19
+ @timout = timeout
20
+ File.open(log_file, 'w') {}
21
+ @pid = start(port, timeout)
22
+ end
23
+
24
+ def self.clear_logs
25
+ Dir.glob(File.expand_path('server_helper*.log', File.dirname(__FILE__))).each do |file|
26
+ File.delete(file)
27
+ end
28
+ end
29
+
30
+ def start(port = nil, timeout = nil)
31
+ port ||= 15672
32
+ timeout ||= 2
33
+ EM.fork_reactor {
34
+ $PORT = port
35
+ EM.start_server('localhost', port, AmqpServer)
36
+ EM.add_timer(timeout) { EM.stop }
37
+ }
38
+ end
39
+
40
+ def stop
41
+ Process.kill('TERM', @pid)
42
+ end
43
+
44
+ def kill
45
+ Process.kill('KILL', @pid)
46
+ end
47
+
48
+ def log
49
+ File.open(log_file).to_a.map{ |l| JSON.parse(l) }
50
+ end
51
+
52
+ def log_file
53
+ File.expand_path("server_helper-port#{@port}.log", File.dirname(__FILE__))
54
+ end
55
+
56
+ end
57
+
58
+ module AmqpServer
59
+ include AMQP::Server
60
+
61
+ # customize log output
62
+ def log(*args)
63
+ args = {:method => args[0], :class => args[1].payload.class, :pid => Process.pid}
64
+ filename = File.expand_path("server_helper-port#{$PORT}.log", File.dirname(__FILE__))
65
+ File.open(filename, 'a') do |f|
66
+ f.write("#{args.to_json}\n")
67
+ end
68
+ end
69
+ end
70
+
71
+ #
72
+ # Helper methods
73
+ #
74
+
75
+ def start_server(port = nil, timeout = nil)
76
+ ServerHelper.new(port, timeout)
77
+ end
@@ -0,0 +1,49 @@
1
+ # encoding: utf-8
2
+
3
+ # add project-relative load paths
4
+ $LOAD_PATH.unshift File.expand_path(File.join(File.dirname(__FILE__), '..', 'lib'))
5
+ $LOAD_PATH.unshift File.expand_path(File.dirname(__FILE__))
6
+
7
+ # require stuff
8
+ require 'rubygems'
9
+
10
+ begin
11
+ require 'mq'
12
+ rescue LoadError => e
13
+ require 'amqp'
14
+ end
15
+ require 'amqp/failover'
16
+
17
+ require 'rspec'
18
+ require 'rspec/autorun'
19
+
20
+
21
+ #
22
+ # Helper methods
23
+ #
24
+
25
+ def wait_while(timeout = 10, retry_interval = 0.1, &block)
26
+ start = Time.now
27
+ while block.call
28
+ break if (Time.now - start).to_i >= timeout
29
+ sleep(retry_interval)
30
+ end
31
+ end
32
+
33
+ # stolen from Pid::running? from daemons gem
34
+ def pid_running?(pid)
35
+ return false unless pid
36
+
37
+ # Check if process is in existence
38
+ # The simplest way to do this is to send signal '0'
39
+ # (which is a single system call) that doesn't actually
40
+ # send a signal
41
+ begin
42
+ Process.kill(0, pid)
43
+ return true
44
+ rescue Errno::ESRCH
45
+ return false
46
+ rescue ::Exception # for example on EPERM (process exists but does not belong to us)
47
+ return true
48
+ end
49
+ end
@@ -0,0 +1,67 @@
1
+ # encoding: utf-8
2
+ $LOAD_PATH.unshift File.expand_path(File.dirname(__FILE__))
3
+
4
+ require 'spec_helper'
5
+
6
+ describe 'AMQP::Failover::Config' do
7
+
8
+ before(:each) do
9
+ configs = [
10
+ {:host => 'rabbit0.local'},
11
+ {:host => 'rabbit1.local'},
12
+ {:host => 'rabbit2.local', :port => 5673}
13
+ ]
14
+ @configs = configs.map { |conf| AMQP.settings.merge(conf) }
15
+ @fail = AMQP::Failover.new(@configs)
16
+ end
17
+
18
+ it "should initialize" do
19
+ fail = AMQP::Failover::Config.new(@configs[0])
20
+ fail.should == @configs[0]
21
+ fail.last_fail.should be_nil
22
+
23
+ now = Time.now
24
+ fail = AMQP::Failover::Config.new(@configs[1], now)
25
+ fail.should == @configs[1]
26
+ fail.last_fail.should == now
27
+ end
28
+
29
+ it "should order properly with #<=>" do
30
+ one_hour_ago = (Time.now - 3600)
31
+ two_hours_ago = (Time.now - 7200)
32
+
33
+ fail = [ AMQP::Failover::Config.new(@configs[0]),
34
+ AMQP::Failover::Config.new(@configs[1], one_hour_ago),
35
+ AMQP::Failover::Config.new(@configs[2], two_hours_ago) ]
36
+
37
+ (fail[1] <=> fail[0]).should == -1
38
+ (fail[0] <=> fail[0]).should == 0
39
+ (fail[0] <=> fail[1]).should == 1
40
+
41
+ (fail[1] <=> fail[2]).should == -1
42
+ (fail[1] <=> fail[1]).should == 0
43
+ (fail[2] <=> fail[1]).should == 1
44
+
45
+ fail.sort[0].last_fail.should == one_hour_ago
46
+ fail.sort[1].last_fail.should == two_hours_ago
47
+ fail.sort[2].last_fail.should == nil
48
+ end
49
+
50
+ it "should be ordered by last_fail" do
51
+ result = [ AMQP::Failover::Config.new(@configs[1], (Time.now - 60)),
52
+ AMQP::Failover::Config.new(@configs[2], (Time.now - (60*25))),
53
+ AMQP::Failover::Config.new(@configs[0], (Time.now - 3600)) ]
54
+
55
+ origin = [ AMQP::Failover::Config.new(@configs[0], (Time.now - 3600)),
56
+ AMQP::Failover::Config.new(@configs[1], (Time.now - 60)),
57
+ AMQP::Failover::Config.new(@configs[2], (Time.now - (60*25))) ]
58
+ origin.sort.should == result
59
+
60
+ origin = [ AMQP::Failover::Config.new(@configs[0]),
61
+ AMQP::Failover::Config.new(@configs[1], (Time.now - 60)),
62
+ AMQP::Failover::Config.new(@configs[2], (Time.now - (60*25))) ]
63
+ origin.sort.should == result
64
+ end
65
+
66
+ end
67
+
@@ -0,0 +1,79 @@
1
+ # encoding: utf-8
2
+ $LOAD_PATH.unshift File.expand_path(File.dirname(__FILE__))
3
+
4
+ require 'spec_helper'
5
+
6
+ describe 'AMQP::Failover::Configurations' do
7
+
8
+ before(:each) do
9
+ @conf = AMQP::Failover::Configurations.new
10
+ @raw_configs = [
11
+ {:host => 'rabbit0.local'},
12
+ {:host => 'rabbit1.local'},
13
+ {:host => 'rabbit2.local', :port => 5673}
14
+ ]
15
+ @configs = @raw_configs.map { |conf| AMQP.settings.merge(conf) }
16
+ end
17
+
18
+ it "should initialize" do
19
+ confs = AMQP::Failover::Configurations.new(@raw_configs)
20
+ confs.each_with_index do |conf, i|
21
+ conf.should be_a(AMQP::Failover::Config)
22
+ conf.should == @configs[i]
23
+ end
24
+ end
25
+
26
+ it "should set and get configs" do
27
+ @conf.primary_ref.should == 0
28
+ @conf.should have(0).items
29
+
30
+ @conf.set(@raw_configs[0])
31
+ @conf.should have(1).items
32
+ @conf.get(0).should == @configs[0]
33
+ @conf[0].should == @configs[0]
34
+
35
+ @conf.set(@raw_configs[1])
36
+ @conf.should have(2).items
37
+ @conf.get(1).should == @configs[1]
38
+ @conf[1].should == @configs[1]
39
+
40
+ # should just create a ref, as config exists
41
+ @conf.set(@raw_configs[1], :the_one)
42
+ @conf.should have(2).items
43
+ @conf.get(1).should == @configs[1]
44
+ @conf[:the_one].should == @configs[1]
45
+
46
+ @conf.load_array(@raw_configs)
47
+ @conf.should have(3).items
48
+ @conf.primary.should == @configs[0]
49
+ @conf.primary_ref = 1
50
+ @conf.primary.should == @configs[1]
51
+ @conf[:primary].should == @configs[1]
52
+ end
53
+
54
+ it "should #find_next" do
55
+ @conf.load(@raw_configs)
56
+ @conf.should have(3).items
57
+ @conf.find_next(@configs[0]).should == @configs[1]
58
+ @conf.find_next(@configs[1]).should == @configs[2]
59
+ @conf.find_next(@configs[2]).should == @configs[0]
60
+ end
61
+
62
+ it "should #load_hash" do
63
+ @conf.should have(0).items
64
+ @conf.load_hash(@raw_configs[0])
65
+ @conf.should have(1).items
66
+ @conf.primary.should == @configs[0]
67
+ end
68
+
69
+ it "should #load_array" do
70
+ @conf.load_hash(:host => 'rabbid-rabbit')
71
+ @conf.should have(1).items
72
+ @conf.load_array(@raw_configs)
73
+ @conf.should have(3).items
74
+ @conf.should == @configs
75
+ @conf.primary.should == @configs[0]
76
+ end
77
+
78
+ end
79
+
@@ -0,0 +1,31 @@
1
+ class ServerDiscoveryHelper < AMQP::Failover::ServerDiscovery
2
+
3
+ class << self
4
+ alias :real_start_monitoring :start_monitoring
5
+ def start_monitoring(*args, &block)
6
+ $called << :start_monitoring
7
+ real_start_monitoring(*args, &block)
8
+ end
9
+ end
10
+
11
+ alias :real_initialize :initialize
12
+ def initialize(*args)
13
+ $called << :initialize
14
+ EM.start_server('127.0.0.1', 9999) if $start_count == 2
15
+ $start_count += 1
16
+ real_initialize(*args)
17
+ end
18
+
19
+ alias :real_connection_completed :connection_completed
20
+ def connection_completed
21
+ $called << :connection_completed
22
+ real_connection_completed
23
+ end
24
+
25
+ alias :real_close_connection :close_connection
26
+ def close_connection
27
+ $called << :close_connection
28
+ real_close_connection
29
+ end
30
+
31
+ end
@@ -0,0 +1,56 @@
1
+ # encoding: utf-8
2
+ $LOAD_PATH.unshift File.expand_path(File.dirname(__FILE__))
3
+
4
+ require 'spec_helper'
5
+ require 'server_discovery_helper'
6
+
7
+ describe 'AMQP::Failover::ServerDiscovery' do
8
+
9
+ before(:each) do
10
+ $called = []
11
+ $start_count = 0
12
+ @args = { :host => 'localhost', :port => 9999 }
13
+ @retry_interval = 0.01
14
+ end
15
+
16
+ after(:all) do
17
+ $called = nil
18
+ $start_count = nil
19
+ end
20
+
21
+ it "should initialize" do
22
+ EM.run {
23
+ EM.start_server('127.0.0.1', 9999)
24
+ @mon = ServerDiscoveryHelper.monitor(@args, @retry_interval) do
25
+ $called << :done_block
26
+ EM.stop_event_loop
27
+ end
28
+ }
29
+ $start_count.should == 1
30
+ $called.should have(5).items
31
+ $called.uniq.should have(5).items
32
+ $called.should include(:start_monitoring)
33
+ $called.should include(:initialize)
34
+ $called.should include(:connection_completed)
35
+ $called.should include(:close_connection)
36
+ $called.should include(:done_block)
37
+ end
38
+
39
+ it "should retry on error" do
40
+ EM.run {
41
+ @mon = ServerDiscoveryHelper.monitor(@args, @retry_interval) do
42
+ $called << :done_block
43
+ EM.stop_event_loop
44
+ end
45
+ }
46
+ $start_count.should >= 3
47
+ $called.should have($start_count + 4).items
48
+ $called.uniq.should have(5).items
49
+ $called.should include(:start_monitoring)
50
+ $called.should include(:initialize)
51
+ $called.should include(:connection_completed)
52
+ $called.should include(:close_connection)
53
+ $called.should include(:done_block)
54
+ end
55
+
56
+ end