pandemic 0.5.4
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/MIT-LICENSE +22 -0
- data/Manifest +27 -0
- data/README.markdown +133 -0
- data/Rakefile +14 -0
- data/examples/client/client.rb +17 -0
- data/examples/client/constitution.txt +865 -0
- data/examples/client/pandemic_client.yml +3 -0
- data/examples/server/pandemic_server.yml +3 -0
- data/examples/server/word_count_server.rb +47 -0
- data/lib/pandemic.rb +42 -0
- data/lib/pandemic/client_side/cluster_connection.rb +194 -0
- data/lib/pandemic/client_side/config.rb +34 -0
- data/lib/pandemic/client_side/connection.rb +40 -0
- data/lib/pandemic/client_side/connection_proxy.rb +15 -0
- data/lib/pandemic/client_side/pandemize.rb +17 -0
- data/lib/pandemic/connection_pool.rb +194 -0
- data/lib/pandemic/mutex_counter.rb +50 -0
- data/lib/pandemic/requests_per_second.rb +31 -0
- data/lib/pandemic/server_side/client.rb +123 -0
- data/lib/pandemic/server_side/config.rb +74 -0
- data/lib/pandemic/server_side/handler.rb +31 -0
- data/lib/pandemic/server_side/peer.rb +211 -0
- data/lib/pandemic/server_side/processor.rb +90 -0
- data/lib/pandemic/server_side/request.rb +92 -0
- data/lib/pandemic/server_side/server.rb +285 -0
- data/lib/pandemic/util.rb +15 -0
- data/pandemic.gemspec +37 -0
- data/test/client_test.rb +87 -0
- data/test/connection_pool_test.rb +133 -0
- data/test/functional_test.rb +57 -0
- data/test/handler_test.rb +31 -0
- data/test/mutex_counter_test.rb +73 -0
- data/test/peer_test.rb +48 -0
- data/test/processor_test.rb +33 -0
- data/test/server_test.rb +171 -0
- data/test/test_helper.rb +24 -0
- data/test/util_test.rb +21 -0
- metadata +141 -0
@@ -0,0 +1,50 @@
|
|
1
|
+
module Pandemic
|
2
|
+
class MutexCounter
|
3
|
+
MAX = (2 ** 30) - 1
|
4
|
+
def initialize(max = MAX)
|
5
|
+
@mutex = Mutex.new
|
6
|
+
@counter = 0
|
7
|
+
@resets = 0
|
8
|
+
@max = max
|
9
|
+
end
|
10
|
+
|
11
|
+
def real_total
|
12
|
+
@mutex.synchronize { (@resets * @max) + @counter }
|
13
|
+
end
|
14
|
+
alias_method :to_i, :real_total
|
15
|
+
|
16
|
+
def value
|
17
|
+
@mutex.synchronize { @counter }
|
18
|
+
end
|
19
|
+
|
20
|
+
def inc
|
21
|
+
@mutex.synchronize do
|
22
|
+
if @counter >= @max
|
23
|
+
@counter = 0 # to avoid Bignum, it's about 4x slower
|
24
|
+
@resets += 1
|
25
|
+
end
|
26
|
+
@counter += 1
|
27
|
+
end
|
28
|
+
end
|
29
|
+
alias_method :next, :inc
|
30
|
+
alias_method :succ, :inc
|
31
|
+
|
32
|
+
# decr only to zero
|
33
|
+
def decr
|
34
|
+
@mutex.synchronize do
|
35
|
+
if @counter > 0
|
36
|
+
@counter -= 1
|
37
|
+
else
|
38
|
+
if @resets > 1
|
39
|
+
@resets -= 1
|
40
|
+
@counter = @max
|
41
|
+
end
|
42
|
+
end
|
43
|
+
@counter
|
44
|
+
end
|
45
|
+
end
|
46
|
+
alias_method :pred, :decr
|
47
|
+
alias_method :prev, :decr
|
48
|
+
|
49
|
+
end
|
50
|
+
end
|
@@ -0,0 +1,31 @@
|
|
1
|
+
module Pandemic
|
2
|
+
class RequestsPerSecond
|
3
|
+
def initialize(sample_size = 10)
|
4
|
+
@hits = Array.new(sample_size + 2)
|
5
|
+
@last_hit_at = nil
|
6
|
+
end
|
7
|
+
|
8
|
+
def hit(now = Time.now.to_i)
|
9
|
+
key = now % @hits.size
|
10
|
+
if @hits[key].nil? || @hits[key][0] != now
|
11
|
+
@hits[key] = [now, 0]
|
12
|
+
end
|
13
|
+
@hits[key][1] += 1
|
14
|
+
@last_hit_at = now
|
15
|
+
end
|
16
|
+
|
17
|
+
def rps(now = Time.now.to_i)
|
18
|
+
return 0 if @last_hit_at.nil?
|
19
|
+
entries_to_go_back = @hits.size - (now - @last_hit_at) - 2
|
20
|
+
return 0 if entries_to_go_back <= 0
|
21
|
+
sum = 0
|
22
|
+
entries_to_go_back.times do |i|
|
23
|
+
now -= 1
|
24
|
+
if @hits[now % @hits.size] && @hits[now % @hits.size][0] == now
|
25
|
+
sum += @hits[now % @hits.size][1]
|
26
|
+
end
|
27
|
+
end
|
28
|
+
return sum.to_f / (@hits.size - 2)
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
@@ -0,0 +1,123 @@
|
|
1
|
+
module Pandemic
|
2
|
+
module ServerSide
|
3
|
+
class Client
|
4
|
+
REQUEST_FLAGS = {:async => 'a'}
|
5
|
+
EMPTY_STRING = ""
|
6
|
+
REQUEST_REGEXP = /^([0-9]+)(?: ([#{REQUEST_FLAGS.values.join('')}]*))?$/
|
7
|
+
class DisconnectClient < Exception; end
|
8
|
+
include Util
|
9
|
+
|
10
|
+
attr_accessor :received_requests, :responded_requests
|
11
|
+
|
12
|
+
def initialize(connection, server)
|
13
|
+
@connection = connection
|
14
|
+
@server = server
|
15
|
+
@received_requests = 0
|
16
|
+
@responded_requests = MutexCounter.new
|
17
|
+
@current_request = nil
|
18
|
+
end
|
19
|
+
|
20
|
+
def listen
|
21
|
+
unless @connection.nil?
|
22
|
+
@listener_thread.kill if @listener_thread
|
23
|
+
@listener_thread = Thread.new do
|
24
|
+
begin
|
25
|
+
while @server.running
|
26
|
+
# debug("Waiting for incoming request")
|
27
|
+
request = @connection.gets
|
28
|
+
# info("Received incoming request")
|
29
|
+
@received_requests += 1
|
30
|
+
|
31
|
+
if request.nil?
|
32
|
+
# debug("Incoming request is nil")
|
33
|
+
@connection.close
|
34
|
+
@connection = nil
|
35
|
+
break
|
36
|
+
elsif request.strip! =~ REQUEST_REGEXP
|
37
|
+
size, flags = $1.to_i, $2.to_s.split(EMPTY_STRING)
|
38
|
+
# debug("Reading request body (size #{size})")
|
39
|
+
body = @connection.read(size)
|
40
|
+
# debug("Finished reading request body")
|
41
|
+
if flags.include?(REQUEST_FLAGS[:async])
|
42
|
+
Thread.new do
|
43
|
+
handle_request(body)
|
44
|
+
@responded_requests.inc
|
45
|
+
end
|
46
|
+
else
|
47
|
+
response = handle_request(body)
|
48
|
+
if response
|
49
|
+
# debug("Writing response to client")
|
50
|
+
|
51
|
+
# the connection could be closed, we'll let it be rescued if it is.
|
52
|
+
@connection.write("#{response.size}\n#{response}")
|
53
|
+
@connection.flush
|
54
|
+
# debug("Finished writing response to client")
|
55
|
+
else
|
56
|
+
# debug("Writing error code to client")
|
57
|
+
|
58
|
+
@connection.write("-1\n")
|
59
|
+
@connection.flush
|
60
|
+
# debug("Finished writing error code to client")
|
61
|
+
end
|
62
|
+
@responded_requests.inc
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
66
|
+
rescue DisconnectClient
|
67
|
+
info("Closing client connection")
|
68
|
+
close_connection
|
69
|
+
rescue Errno::EPIPE
|
70
|
+
info("Connection to client lost")
|
71
|
+
close_connection
|
72
|
+
rescue Exception => e
|
73
|
+
warn("Unhandled exception in client listen thread:\n#{e.inspect}\n#{e.backtrace.join("\n")}")
|
74
|
+
ensure
|
75
|
+
@current_request.cancel! if @current_request
|
76
|
+
@server.client_closed(self)
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|
80
|
+
return self
|
81
|
+
end
|
82
|
+
|
83
|
+
def close
|
84
|
+
@listener_thread.raise(DisconnectClient)
|
85
|
+
end
|
86
|
+
|
87
|
+
|
88
|
+
|
89
|
+
def handle_request(request)
|
90
|
+
@current_request = Request.new(request)
|
91
|
+
response = begin
|
92
|
+
@server.handle_client_request(@current_request)
|
93
|
+
rescue Exception => e
|
94
|
+
warn("Unhandled exception in handle client request:\n#{e.inspect}\n#{e.backtrace.join("\n")}")
|
95
|
+
nil
|
96
|
+
end
|
97
|
+
@current_request = nil
|
98
|
+
return response
|
99
|
+
end
|
100
|
+
|
101
|
+
private
|
102
|
+
def close_connection
|
103
|
+
@connection.close unless @connection.nil? || @connection.closed?
|
104
|
+
end
|
105
|
+
|
106
|
+
def signature
|
107
|
+
@signature ||= @connection.peeraddr.values_at(3,1).join(":")
|
108
|
+
end
|
109
|
+
|
110
|
+
def debug(msg)
|
111
|
+
logger.debug("Client #{signature}") {msg}
|
112
|
+
end
|
113
|
+
|
114
|
+
def info(msg)
|
115
|
+
logger.info("Client #{signature}") {msg}
|
116
|
+
end
|
117
|
+
|
118
|
+
def warn(msg)
|
119
|
+
logger.warn("Client #{signature}") {msg}
|
120
|
+
end
|
121
|
+
end
|
122
|
+
end
|
123
|
+
end
|
@@ -0,0 +1,74 @@
|
|
1
|
+
module Pandemic
|
2
|
+
module ServerSide
|
3
|
+
class Config
|
4
|
+
class << self
|
5
|
+
attr_accessor :bind_to, :servers, :response_timeout, :fork_for_processor, :pid_file
|
6
|
+
def load
|
7
|
+
parse_args!
|
8
|
+
raise "Interface to bind to is nil." unless @bind_to
|
9
|
+
end
|
10
|
+
|
11
|
+
def get(*args)
|
12
|
+
args.size == 1 ? @options[args.first] : @options.values_at(*args) if @options
|
13
|
+
end
|
14
|
+
|
15
|
+
private
|
16
|
+
|
17
|
+
def parse_args!
|
18
|
+
config_path = "pandemic_server.yml"
|
19
|
+
index = nil
|
20
|
+
attach = nil
|
21
|
+
|
22
|
+
@bind_to = nil
|
23
|
+
@pid_file = nil
|
24
|
+
OptionParser.new do |opts|
|
25
|
+
opts.on("-c", "--config [CONFIG-PATH]", "Specify the path to the config file") do |path|
|
26
|
+
config_path = path
|
27
|
+
end
|
28
|
+
|
29
|
+
opts.on("-i", "--index [SERVER-INDEX]", "Specify the index of the server to attach to from the YAML file") do |i|
|
30
|
+
index = i
|
31
|
+
end
|
32
|
+
|
33
|
+
opts.on("-a", "--attach [SERVER:PORT]", "Specify the host and port to attach to") do |a|
|
34
|
+
attach = a
|
35
|
+
end
|
36
|
+
|
37
|
+
opts.on("-P", "--pid-file [PATH]", "Specify the path to write the PID to") do |path|
|
38
|
+
@pid_file = path
|
39
|
+
end
|
40
|
+
end.parse!
|
41
|
+
|
42
|
+
read_config_file(config_path)
|
43
|
+
|
44
|
+
if index
|
45
|
+
index = index.to_i if @server_map.is_a?(Array)
|
46
|
+
server = @server_map[index]
|
47
|
+
|
48
|
+
@bind_to = if server.is_a?(Hash)
|
49
|
+
@options = server.values.first # there should only be one
|
50
|
+
@server_map[index].keys.first
|
51
|
+
else
|
52
|
+
server
|
53
|
+
end
|
54
|
+
elsif attach
|
55
|
+
@bind_to = attach
|
56
|
+
end
|
57
|
+
|
58
|
+
end
|
59
|
+
|
60
|
+
def read_config_file(path)
|
61
|
+
yaml = YAML.load_file(path)
|
62
|
+
|
63
|
+
@server_map = yaml['servers'] || []
|
64
|
+
@servers = @server_map.is_a?(Hash) ? @server_map.values : @server_map
|
65
|
+
@servers = @servers.collect { |s| s.is_a?(Hash) ? s.keys.first : s }
|
66
|
+
|
67
|
+
@response_timeout = (yaml['response_timeout'] || 1).to_f
|
68
|
+
|
69
|
+
@fork_for_processor = yaml['fork_for_processor']
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
@@ -0,0 +1,31 @@
|
|
1
|
+
module Pandemic
|
2
|
+
module ServerSide
|
3
|
+
class Handler
|
4
|
+
def config
|
5
|
+
Config
|
6
|
+
end
|
7
|
+
|
8
|
+
def partition(request, servers)
|
9
|
+
map = {}
|
10
|
+
servers.each do |server, status|
|
11
|
+
if status != :disconnected
|
12
|
+
map[server] = request.body
|
13
|
+
end
|
14
|
+
end
|
15
|
+
map
|
16
|
+
end
|
17
|
+
|
18
|
+
def reduce(request)
|
19
|
+
request.responses.join("")
|
20
|
+
end
|
21
|
+
|
22
|
+
def process(body)
|
23
|
+
body
|
24
|
+
end
|
25
|
+
|
26
|
+
def filter_alive(servers)
|
27
|
+
servers.keys.select{|k| servers[k] != :disconnected}
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
@@ -0,0 +1,211 @@
|
|
1
|
+
module Pandemic
|
2
|
+
module ServerSide
|
3
|
+
class Peer
|
4
|
+
class PeerUnavailableException < StandardError; end
|
5
|
+
include Util
|
6
|
+
attr_reader :host, :port
|
7
|
+
|
8
|
+
def initialize(addr, server)
|
9
|
+
@host, @port = host_port(addr)
|
10
|
+
@server = server
|
11
|
+
@pending_requests = with_mutex({})
|
12
|
+
@incoming_connection_listeners = []
|
13
|
+
@inc_threads_mutex = Mutex.new
|
14
|
+
initialize_connection_pool
|
15
|
+
end
|
16
|
+
|
17
|
+
def connect
|
18
|
+
# debug("Forced connection to peer")
|
19
|
+
@connection_pool.connect
|
20
|
+
end
|
21
|
+
|
22
|
+
def disconnect
|
23
|
+
# debug("Disconnecting from peer")
|
24
|
+
@connection_pool.disconnect
|
25
|
+
end
|
26
|
+
|
27
|
+
def connected?
|
28
|
+
@connection_pool.connected?
|
29
|
+
end
|
30
|
+
|
31
|
+
def client_request(request, body)
|
32
|
+
# debug("Sending client's request to peer")
|
33
|
+
# debug("Connection pool has #{@connection_pool.available_count} of #{@connection_pool.connections_count} connections available")
|
34
|
+
|
35
|
+
successful = true
|
36
|
+
@pending_requests.synchronize do
|
37
|
+
@pending_requests[request.hash] = request
|
38
|
+
end
|
39
|
+
begin
|
40
|
+
@connection_pool.with_connection do |connection|
|
41
|
+
if connection && !connection.closed?
|
42
|
+
# debug("Writing client's request")
|
43
|
+
connection.write("P#{request.hash}#{[body.size].pack('N')}#{body}")
|
44
|
+
connection.flush
|
45
|
+
# debug("Finished writing client's request")
|
46
|
+
else
|
47
|
+
successful = false
|
48
|
+
end
|
49
|
+
end
|
50
|
+
rescue Exception
|
51
|
+
@pending_requests.synchronize { @pending_requests.delete(request.hash) }
|
52
|
+
raise
|
53
|
+
else
|
54
|
+
if !successful
|
55
|
+
@pending_requests.synchronize { @pending_requests.delete(request.hash) }
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
def add_incoming_connection(conn)
|
61
|
+
# debug("Adding incoming connection")
|
62
|
+
|
63
|
+
connect # if we're not connected, we should be
|
64
|
+
|
65
|
+
|
66
|
+
thread = Thread.new(conn) do |connection|
|
67
|
+
begin
|
68
|
+
# debug("Incoming connection thread started")
|
69
|
+
while @server.running
|
70
|
+
# debug("Listening for incoming requests")
|
71
|
+
request = connection.read(15)
|
72
|
+
# debug("Read incoming request from peer")
|
73
|
+
|
74
|
+
if request.nil?
|
75
|
+
# debug("Incoming connection request is nil")
|
76
|
+
break
|
77
|
+
else
|
78
|
+
# debug("Received incoming (#{request.strip})")
|
79
|
+
handle_incoming_request(request, connection) if request =~ /^P/
|
80
|
+
handle_incoming_response(request, connection) if request =~ /^R/
|
81
|
+
end
|
82
|
+
end
|
83
|
+
rescue Exception => e
|
84
|
+
warn("Unhandled exception in peer listener thread:\n#{e.inspect}\n#{e.backtrace.join("\n")}")
|
85
|
+
ensure
|
86
|
+
# debug("Incoming connection closing")
|
87
|
+
conn.close if conn && !conn.closed?
|
88
|
+
@inc_threads_mutex.synchronize { @incoming_connection_listeners.delete(Thread.current)}
|
89
|
+
if @incoming_connection_listeners.empty?
|
90
|
+
disconnect
|
91
|
+
end
|
92
|
+
end
|
93
|
+
end
|
94
|
+
|
95
|
+
@inc_threads_mutex.synchronize {@incoming_connection_listeners.push(thread) if thread.alive? }
|
96
|
+
end
|
97
|
+
|
98
|
+
private
|
99
|
+
def initialize_connection_pool
|
100
|
+
return if @connection_pool
|
101
|
+
@connection_pool = ConnectionPool.new(:connect_at_define => false)
|
102
|
+
|
103
|
+
@connection_pool.create_connection do
|
104
|
+
connection = nil
|
105
|
+
retries = 0
|
106
|
+
begin
|
107
|
+
connection = TCPSocket.new(@host, @port)
|
108
|
+
rescue Errno::ETIMEDOUT, Errno::ECONNREFUSED => e
|
109
|
+
connection = nil
|
110
|
+
# debug("Connection timeout or refused: #{e.inspect}")
|
111
|
+
if retries == 0
|
112
|
+
# debug("Retrying connection")
|
113
|
+
retries += 1
|
114
|
+
sleep 0.01
|
115
|
+
retry
|
116
|
+
end
|
117
|
+
rescue Exception => e
|
118
|
+
warn("Unhandled exception in create connection block:\n#{e.inspect}\n#{e.backtrace.join("\n")}")
|
119
|
+
end
|
120
|
+
if connection
|
121
|
+
connection.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1) if TCP_NO_DELAY_AVAILABLE
|
122
|
+
connection.write("SERVER #{@server.signature}\n")
|
123
|
+
end
|
124
|
+
connection
|
125
|
+
end
|
126
|
+
end
|
127
|
+
|
128
|
+
def handle_incoming_request(request, connection)
|
129
|
+
# debug("Identified as request")
|
130
|
+
hash = request[1,10]
|
131
|
+
size = request[11, 4].unpack('N').first
|
132
|
+
# debug("Incoming request: #{hash} #{size}")
|
133
|
+
begin
|
134
|
+
# debug("Reading request body")
|
135
|
+
request_body = connection.read(size)
|
136
|
+
# debug("Finished reading request body")
|
137
|
+
rescue EOFError, TruncatedDataError
|
138
|
+
# debug("Failed to read request body")
|
139
|
+
# TODO: what to do here?
|
140
|
+
return false
|
141
|
+
rescue Exception => e
|
142
|
+
warn("Unhandled exception in incoming request read:\n#{e.inspect}\n#{e.backtrace.join("\n")}")
|
143
|
+
end
|
144
|
+
# debug("Processing body")
|
145
|
+
process_request(hash, request_body)
|
146
|
+
end
|
147
|
+
|
148
|
+
def handle_incoming_response(response, connection)
|
149
|
+
hash = response[1,10]
|
150
|
+
size = response[11, 4].unpack('N').first
|
151
|
+
# debug("Incoming response: #{hash} #{size}")
|
152
|
+
begin
|
153
|
+
# debug("Reading response body")
|
154
|
+
response_body = connection.read(size)
|
155
|
+
# debug("Finished reading response body")
|
156
|
+
rescue EOFError, TruncatedDataError
|
157
|
+
# debug("Failed to read response body")
|
158
|
+
# TODO: what to do here?
|
159
|
+
return false
|
160
|
+
rescue Exception => e
|
161
|
+
warn("Unhandled exception in incoming response read:\n#{e.inspect}\n#{e.backtrace.join("\n")}")
|
162
|
+
end
|
163
|
+
process_response(hash, response_body)
|
164
|
+
end
|
165
|
+
|
166
|
+
|
167
|
+
def process_request(hash, body)
|
168
|
+
Thread.new do
|
169
|
+
begin
|
170
|
+
# debug("Starting processing thread (#{hash})")
|
171
|
+
response = @server.process(body)
|
172
|
+
# debug("Processing finished (#{hash})")
|
173
|
+
@connection_pool.with_connection do |connection|
|
174
|
+
# debug( "Sending response (#{hash})")
|
175
|
+
connection.write("R#{hash}#{[response.size].pack('N')}#{response}")
|
176
|
+
connection.flush
|
177
|
+
# debug( "Finished sending response (#{hash})")
|
178
|
+
end
|
179
|
+
rescue Exception => e
|
180
|
+
warn("Unhandled exception in process request thread:\n#{e.inspect}\n#{e.backtrace.join("\n")}")
|
181
|
+
end
|
182
|
+
end
|
183
|
+
end
|
184
|
+
|
185
|
+
def process_response(hash, body)
|
186
|
+
Thread.new do
|
187
|
+
begin
|
188
|
+
# debug("Finding original request (#{hash})")
|
189
|
+
original_request = @pending_requests.synchronize { @pending_requests.delete(hash) }
|
190
|
+
if original_request
|
191
|
+
# debug("Found original request, adding response")
|
192
|
+
original_request.add_response(body)
|
193
|
+
else
|
194
|
+
warn("Original response not found (#{hash})")
|
195
|
+
end
|
196
|
+
rescue Exception => e
|
197
|
+
warn("Unhandled exception in process response thread:\n#{e.inspect}\n#{e.backtrace.join("\n")}")
|
198
|
+
end
|
199
|
+
end
|
200
|
+
end
|
201
|
+
|
202
|
+
def debug(msg)
|
203
|
+
logger.debug("Peer #{@host}:#{@port}") { msg }
|
204
|
+
end
|
205
|
+
|
206
|
+
def warn(msg)
|
207
|
+
logger.warn("Peer #{@host}:#{@port}") { msg }
|
208
|
+
end
|
209
|
+
end
|
210
|
+
end
|
211
|
+
end
|