ruby_skynet 0.1.2 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
data/Gemfile CHANGED
@@ -6,6 +6,9 @@ end
6
6
 
7
7
  gem "semantic_logger"
8
8
  gem "resilient_socket"
9
+ # Thread Safe Hash and Array
10
+ gem "thread_safe"
11
+ gem "gene_pool"
9
12
  # For looking up Service entries in Doozer
10
13
  gem "multi_json"
11
14
  gem "ruby_protobuf"
data/Gemfile.lock CHANGED
@@ -9,7 +9,7 @@ GEM
9
9
  bson (~> 1.7.0)
10
10
  i18n (0.6.1)
11
11
  multi_json (1.3.6)
12
- resilient_socket (0.2.0)
12
+ resilient_socket (0.2.1)
13
13
  semantic_logger
14
14
  ruby_protobuf (0.4.11)
15
15
  semantic_logger (0.7.0)
data/dev.log ADDED
Binary file
@@ -1,7 +1,4 @@
1
1
  require 'bson'
2
- require 'sync_attr'
3
- require 'multi_json'
4
-
5
2
  #
6
3
  # RubySkynet Client
7
4
  #
@@ -13,124 +10,36 @@ module RubySkynet
13
10
  class Client
14
11
  include SyncAttr
15
12
 
16
- # Default doozer configuration
17
- # To replace this default, set the config as follows:
18
- # RubySkynet::Client.doozer_config = { .... }
19
- sync_attr_accessor :doozer_config do
20
- {
21
- :server => '127.0.0.1:8046',
22
- :read_timeout => 5,
23
- :connect_timeout => 3,
24
- :connect_retry_interval => 0.1,
25
- :connect_retry_count => 3
26
- }
27
- end
28
-
29
- # Lazy initialize Doozer Client
30
- sync_cattr_reader :doozer do
31
- Doozer::Client.new
32
- end
33
-
34
- # Create a client connection, call the supplied block and close the connection on
35
- # completion of the block
36
- #
37
- # Example
38
- #
39
- # require 'ruby_skynet'
40
- # SemanticLogger.default_level = :trace
41
- # SemanticLogger.appenders << SemanticLogger::Appender::File(STDOUT)
42
- # RubySkynet::Client.connect('TutorialService') do |tutorial_service|
43
- # p tutorial_service.call(:value => 5)
44
- # end
45
- def self.connect(service_name, params={})
46
- begin
47
- client = self.new(service_name, params)
48
- yield(client)
49
- ensure
50
- client.close if client
51
- end
52
- end
53
-
54
13
  # Returns a new RubySkynet Client for the named service
55
14
  #
15
+ # Calls to an instance of the Client are thread-safe and can be called
16
+ # concurrently from multiple threads at the same time
17
+ #
56
18
  # Parameters:
57
19
  # :service_name
58
20
  # Name of the service to look for and connect to on Skynet
59
21
  #
60
- # :doozer_servers [Array of String]
61
- # Array of URL's of doozer servers to connect to with port numbers
62
- # ['server1:2000', 'server2:2000']
22
+ # :version
23
+ # Optional version number of the service in Skynet
24
+ # Default: '*' being the latest version of the service
63
25
  #
64
- # The second server will only be attempted once the first server
65
- # cannot be connected to or has timed out on connect
66
- # A read failure or timeout will not result in switching to the second
67
- # server, only a connection failure or during an automatic reconnect
26
+ # :region
27
+ # Optional region for this service in Skynet
28
+ # Default: 'Development'
68
29
  #
69
- # :read_timeout [Float]
70
- # Time in seconds to timeout on read
71
- # Can be overridden by supplying a timeout in the read call
72
- # Default: 60
73
- #
74
- # :connect_timeout [Float]
75
- # Time in seconds to timeout when trying to connect to the server
76
- # Default: Half of the :read_timeout ( 30 seconds )
30
+ # Example
77
31
  #
78
- # :connect_retry_count [Fixnum]
79
- # Number of times to retry connecting when a connection fails
80
- # Default: 10
32
+ # require 'ruby_skynet'
33
+ # SemanticLogger.default_level = :trace
34
+ # SemanticLogger.appenders << SemanticLogger::Appender::File(STDOUT)
81
35
  #
82
- # :connect_retry_interval [Float]
83
- # Number of seconds between connection retry attempts after the first failed attempt
84
- # Default: 0.5
85
- def initialize(service_name, params = {})
36
+ # tutorial_service = RubySkynet::Client.new('TutorialService')
37
+ # p tutorial_service.call('Add', :value => 5)
38
+ def initialize(service_name, version='*', region='Development')
86
39
  @service_name = service_name
87
- @logger = SemanticLogger::Logger.new("#{self.class.name}: #{service_name}")
88
-
89
- # User configurable options
90
- params[:read_timeout] ||= 60
91
- params[:connect_timeout] ||= 30
92
- params[:connect_retry_interval] ||= 0.1
93
- params[:connect_retry_count] ||= 5
94
-
95
- # If Server name and port of where Skynet Service is running
96
- # is not supplied look for it in Doozer
97
- unless params[:server] || params[:servers]
98
- params[:server] = self.class.server_for(service_name)
99
- end
100
-
101
- # Disable buffering the send since it is a RPC call
102
- params[:buffered] = false
103
-
104
- @logger.trace "Socket Connection parameters", params
105
-
106
- # For each new connection perform the Skynet handshake
107
- params[:on_connect] = Proc.new do |socket|
108
- # Reset user_data on each connection
109
- socket.user_data = 0
110
-
111
- # Receive Service Handshake
112
- # Registered bool
113
- # Registered indicates the state of this service. If it is false, the connection will
114
- # close immediately and the client should look elsewhere for this service.
115
- #
116
- # ClientID string
117
- # ClientID is a UUID that is used by the client to identify itself in RPC requests.
118
- @logger.debug "Waiting for Service Handshake"
119
- service_handshake = self.class.read_bson_document(socket)
120
- @logger.trace 'Service Handshake', service_handshake
121
-
122
- # #TODO When a reconnect returns registered == false we need to go back to doozer
123
- @registered = service_handshake['registered']
124
- @client_id = service_handshake['clientid']
125
-
126
- # Send blank ClientHandshake
127
- client_handshake = { 'clientid' => @client_id }
128
- @logger.debug "Sending Client Handshake"
129
- @logger.trace 'Client Handshake', client_handshake
130
- socket.write(BSON.serialize(client_handshake))
131
- end
132
-
133
- @socket = ResilientSocket::TCPClient.new(params)
40
+ @logger = SemanticLogger::Logger.new("#{self.class.name}: #{service_name}/#{version}/#{region}")
41
+ @version = version
42
+ @region = region
134
43
  end
135
44
 
136
45
  # Performs a synchronous call to the Skynet Service
@@ -145,149 +54,28 @@ module RubySkynet
145
54
  #
146
55
  # Raises RubySkynet::ProtocolError
147
56
  # Raises RubySkynet::SkynetException
148
- def call(method_name, parameters)
57
+ def call(method_name, parameters, connection_params={})
149
58
  # Skynet requires BSON RPC Calls to have the following format:
150
59
  # https://github.com/bketelsen/skynet/blob/protocol/protocol.md
151
60
  request_id = BSON::ObjectId.new.to_s
152
61
  @logger.tagged request_id do
153
62
  @logger.benchmark_info "Called Skynet Service: #{@service_name}.#{method_name}" do
154
-
155
- # Resilient Send
156
- retry_count = 0
157
- @socket.retry_on_connection_failure do |socket|
158
- # user_data is maintained per session and a different session could
159
- # be supplied with each retry
160
- socket.user_data ||= 0
161
- header = {
162
- 'servicemethod' => "#{@service_name}.Forward",
163
- 'seq' => socket.user_data,
164
- }
165
- @logger.debug "Sending Header"
166
- @logger.trace 'Header', header
167
- socket.write(BSON.serialize(header))
168
-
169
- @logger.trace 'Parameters:', parameters
170
-
171
- # The parameters are placed in the request object in BSON serialized
172
- # form
173
- request = {
174
- 'clientid' => @client_id,
175
- 'in' => BSON.serialize(parameters).to_s,
176
- 'method' => method_name.to_s,
177
- 'requestinfo' => {
178
- 'requestid' => request_id,
179
- # Increment retry count to indicate that the request may have been tried previously
180
- # TODO: this should be incremented if request is retried,
181
- 'retrycount' => retry_count,
182
- # TODO: this should be forwarded along in case of services also
183
- # being a client and calling additional services. If empty it will
184
- # be stuffed with connecting address
185
- 'originaddress' => ''
186
- }
187
- }
188
-
189
- @logger.debug "Sending Request"
190
- @logger.trace 'Request', request
191
- socket.write(BSON.serialize(request))
192
- end
193
-
194
- # Once send is successful it could have been processed, so we can no
195
- # longer retry now otherwise we could create a duplicate
196
- # retry_count += 1
197
-
198
- # Read header first as a separate BSON document
199
- @logger.debug "Reading header from server"
200
- header = self.class.read_bson_document(@socket)
201
- @logger.debug 'Header', header
202
-
203
- # Read the BSON response document
204
- @logger.debug "Reading response from server"
205
- response = self.class.read_bson_document(@socket)
206
- @logger.trace 'Response', response
207
-
208
- # Ensure the sequence number in the response header matches the
209
- # sequence number sent in the request
210
- if seq_no = header['seq']
211
- raise ProtocolError.new("Incorrect Response received, expected seq=#{@socket.user_data}, received: #{header.inspect}") if seq_no != @socket.user_data
212
- else
213
- raise ProtocolError.new("Invalid Response header, missing 'seq': #{header.inspect}")
214
- end
215
-
216
- # Increment Sequence number only on successful response
217
- @socket.user_data += 1
218
-
219
- # If an error is returned from Skynet raise a Skynet exception
220
- if error = header['error']
221
- raise SkynetException.new(error) if error.to_s.length > 0
222
- end
223
-
224
- # If an error is returned from the service raise a Service exception
225
- if error = response['error']
226
- raise ServiceException.new(error) if error.to_s.length > 0
63
+ retries = 0
64
+ # If it cannot connect to a server, try a different server
65
+ begin
66
+ Connection.with_connection(Registry.server_for(@service_name, @version, @region), connection_params) do |connection|
67
+ connection.rpc_call(request_id, @service_name, method_name, parameters)
68
+ end
69
+ rescue ResilientSocket::ConnectionFailure => exc
70
+ if (retries < 3) && exc.cause.is_a?(Errno::ECONNREFUSED)
71
+ retries += 1
72
+ retry
73
+ end
74
+ # TODO rescue ServiceUnavailable retry x times until the service becomes available
227
75
  end
228
-
229
- # Return Value
230
- # The return value is inside the response object, it's a byte array of it's own and needs to be deserialized
231
- result = BSON.deserialize(response['out'])
232
- @logger.trace 'Return Value', result
233
- result
234
76
  end
235
77
  end
236
78
  end
237
79
 
238
- # Returns a BSON document read from the socket.
239
- # Returns nil if the operation times out or if a network
240
- # connection failure occurs
241
- def self.read_bson_document(socket)
242
- bytebuf = BSON::ByteBuffer.new
243
- # Read 4 byte size of following BSON document
244
- bytes = ''
245
- socket.read(4, bytes)
246
-
247
- # Read BSON document
248
- sz = bytes.unpack("V")[0]
249
- raise "Invalid Data received from server:#{bytes.inspect}" unless sz
250
-
251
- bytebuf.append!(bytes)
252
- bytes = ''
253
- sz -= 4
254
- until bytes.size >= sz
255
- buf = ''
256
- socket.read(sz, buf)
257
- bytes << buf
258
- end
259
- bytebuf.append!(bytes)
260
- return BSON.deserialize(bytebuf)
261
- end
262
-
263
- def close()
264
- @socket.close
265
- end
266
-
267
- ##############################
268
- #protected
269
-
270
- # Returns [Array] of the hostname and port pair [String] that implements a particular service
271
- # Performs a doozer lookup to find the servers
272
- #
273
- # service_name:
274
- # version: Version of service to locate
275
- # Default: Find latest version
276
- def self.registered_implementers(service_name, version = '*', region = 'Development')
277
- hosts = []
278
- doozer.walk("/services/#{service_name}/#{version}/#{region}/*/*").each do |node|
279
- entry = MultiJson.load(node.value)
280
- hosts << entry if entry['Registered']
281
- end
282
- hosts
283
- end
284
-
285
- # Randomly returns a server that implements the requested service
286
- def self.server_for(service_name, version = '*', region = 'Development')
287
- hosts = registered_implementers(service_name, version, region)
288
- service = hosts[rand(hosts.size)]['Config']['ServiceAddr']
289
- "#{service['IPAddress']}:#{service['Port']}"
290
- end
291
-
292
80
  end
293
81
  end
@@ -0,0 +1,269 @@
1
+ require 'bson'
2
+ require 'gene_pool'
3
+ require 'thread_safe'
4
+
5
+ #
6
+ # RubySkynet Connection
7
+ #
8
+ # Handles connecting to Skynet Servers as a host:port pair
9
+ #
10
+ module RubySkynet
11
+ class Connection
12
+ include SyncAttr
13
+
14
+ # Returns the underlying socket being used by a Connection instance
15
+ attr_reader :socket
16
+
17
+ # Default Pool configuration
18
+ sync_cattr_accessor :pool_config do
19
+ {
20
+ :pool_size => 30, # Maximum number of connections to any one server
21
+ :warn_timeout => 2, # Log a warning if no connections are available after the :warn_timeout seconds
22
+ :timeout => 10, # Raise a Timeout exception if no connections are available after the :timeout seconds
23
+ :idle_timeout => 600, # Renew a connection if it has been idle for this period of time
24
+ }
25
+ end
26
+
27
+ # Logging instance for the connection pool
28
+ sync_cattr_reader :logger do
29
+ SemanticLogger::Logger.new(self)
30
+ end
31
+
32
+ # For each server there is a connection pool keyed on the
33
+ # server address: 'host:port'
34
+ @@connection_pools = ThreadSafe::Hash.new
35
+
36
+ # Returns a new RubySkynet connection to the server
37
+ #
38
+ # Parameters:
39
+ # :read_timeout [Float]
40
+ # Time in seconds to timeout on read
41
+ # Can be overridden by supplying a timeout in the read call
42
+ # Default: 60
43
+ #
44
+ # :connect_timeout [Float]
45
+ # Time in seconds to timeout when trying to connect to the server
46
+ # Default: Half of the :read_timeout ( 30 seconds )
47
+ #
48
+ # :connect_retry_count [Fixnum]
49
+ # Number of times to retry connecting when a connection fails
50
+ # Default: 10
51
+ #
52
+ # :connect_retry_interval [Float]
53
+ # Number of seconds between connection retry attempts after the first failed attempt
54
+ # Default: 0.5
55
+ def initialize(server, params = {})
56
+ @logger = SemanticLogger::Logger.new("#{self.class.name}: #{server}")
57
+
58
+ # User configurable options
59
+ params[:read_timeout] ||= 60
60
+ params[:connect_timeout] ||= 30
61
+ params[:connect_retry_interval] ||= 0.1
62
+ params[:connect_retry_count] ||= 5
63
+
64
+ # Disable send buffering since it is a RPC call
65
+ params[:buffered] = false
66
+
67
+ # For each new connection perform the Skynet handshake
68
+ params[:on_connect] = Proc.new do |socket|
69
+ # Reset user_data on each connection
70
+ socket.user_data = {
71
+ :seq => 0,
72
+ :logger => @logger
73
+ }
74
+
75
+ # Receive Service Handshake
76
+ # Registered bool
77
+ # Registered indicates the state of this service. If it is false, the connection will
78
+ # close immediately and the client should look elsewhere for this service.
79
+ #
80
+ # ClientID string
81
+ # ClientID is a UUID that is used by the client to identify itself in RPC requests.
82
+ @logger.debug "Waiting for Service Handshake"
83
+ service_handshake = self.class.read_bson_document(socket)
84
+ @logger.trace 'Service Handshake', service_handshake
85
+
86
+ # #TODO When a reconnect returns registered == false need to throw an exception
87
+ # so that this host connection is not used
88
+ registered = service_handshake['registered']
89
+ client_id = service_handshake['clientid']
90
+ socket.user_data[:client_id] = client_id
91
+
92
+ # Send blank ClientHandshake
93
+ client_handshake = { 'clientid' => client_id }
94
+ @logger.debug "Sending Client Handshake"
95
+ @logger.trace 'Client Handshake', client_handshake
96
+ socket.write(BSON.serialize(client_handshake))
97
+ end
98
+
99
+ # To prevent strange issues if user incorrectly supplies server names
100
+ params.delete(:servers)
101
+ params[:server] = server
102
+
103
+ @socket = ResilientSocket::TCPClient.new(params)
104
+ end
105
+
106
+ # Performs a synchronous call to a Skynet server
107
+ #
108
+ # Parameters:
109
+ # service_name [String|Symbol]:
110
+ # Name of the method to pass in the request
111
+ # method_name [String|Symbol]:
112
+ # Name of the method to pass in the request
113
+ # parameters [Hash]:
114
+ # Parameters to pass in the request
115
+ # idempotent [True|False]:
116
+ # If the request can be applied again to the server without changing its state
117
+ # Set to true to retry the entire request after the send is successful
118
+ #
119
+ # Returns the Hash result returned from the Skynet Service
120
+ #
121
+ # Raises RubySkynet::ProtocolError
122
+ # Raises RubySkynet::SkynetException
123
+ def rpc_call(request_id, service_name, method_name, parameters, idempotent=false)
124
+ retry_count = 0
125
+ header = nil
126
+ response = nil
127
+
128
+ socket.retry_on_connection_failure do |socket|
129
+ header = {
130
+ 'servicemethod' => "#{service_name}.Forward",
131
+ 'seq' => socket.user_data[:seq]
132
+ }
133
+
134
+ @logger.debug "Sending Header"
135
+ @logger.trace 'Header', header
136
+ socket.write(BSON.serialize(header))
137
+
138
+ # The parameters are placed in the request object in BSON serialized form
139
+ request = {
140
+ 'clientid' => socket.user_data[:client_id],
141
+ 'in' => BSON.serialize(parameters).to_s,
142
+ 'method' => method_name.to_s,
143
+ 'requestinfo' => {
144
+ 'requestid' => request_id,
145
+ # Increment retry count to indicate that the request may have been tried previously
146
+ 'retrycount' => retry_count,
147
+ # TODO: this should be forwarded along in case of services also
148
+ # being a client and calling additional services. If empty it will
149
+ # be stuffed with connecting address
150
+ 'originaddress' => ''
151
+ }
152
+ }
153
+
154
+ @logger.debug "Sending Request"
155
+ @logger.trace 'Request', request
156
+ @logger.trace 'Parameters:', parameters
157
+ socket.write(BSON.serialize(request))
158
+
159
+ # Since Send does not affect state on the server we can also retry reads
160
+ if idempotent
161
+ @logger.debug "Reading header from server"
162
+ header = self.class.read_bson_document(socket)
163
+ @logger.debug 'Response Header', header
164
+
165
+ # Read the BSON response document
166
+ @logger.debug "Reading response from server"
167
+ response = self.class.read_bson_document(socket)
168
+ @logger.trace 'Response', response
169
+ end
170
+ end
171
+
172
+ # Perform the read outside the retry block since a successful write
173
+ # means that the servers state may have been changed
174
+ unless idempotent
175
+ # Read header first as a separate BSON document
176
+ @logger.debug "Reading header from server"
177
+ header = self.class.read_bson_document(socket)
178
+ @logger.debug 'Response Header', header
179
+
180
+ # Read the BSON response document
181
+ @logger.debug "Reading response from server"
182
+ response = self.class.read_bson_document(socket)
183
+ @logger.trace 'Response', response
184
+ end
185
+
186
+ # Ensure the sequence number in the response header matches the
187
+ # sequence number sent in the request
188
+ seq_no = header['seq']
189
+ if seq_no != socket.user_data[:seq]
190
+ raise ProtocolError.new("Incorrect Response received, expected seq=#{socket.user_data[:seq]}, received: #{header.inspect}")
191
+ end
192
+
193
+ # Increment Sequence number only on successful response
194
+ socket.user_data[:seq] += 1
195
+
196
+ # If an error is returned from Skynet raise a Skynet exception
197
+ error = header['error']
198
+ raise SkynetException.new(error) if error.to_s.length > 0
199
+
200
+ # If an error is returned from the service raise a Service exception
201
+ error = response['error']
202
+ raise ServiceException.new(error) if error.to_s.length > 0
203
+
204
+ # Return Value
205
+ # The return value is inside the response object, it's a byte array of it's own and needs to be deserialized
206
+ result = BSON.deserialize(response['out'])
207
+ @logger.trace 'Return Value', result
208
+ result
209
+ end
210
+
211
+ # Execute the supplied block with a connection from the pool
212
+ def self.with_connection(server, params={}, &block)
213
+ (@@connection_pools[server] ||= new_connection_pool(server, params)).with_connection(&block)
214
+ end
215
+
216
+ def close
217
+ @socket.close if @socket
218
+ end
219
+
220
+ ########################
221
+ protected
222
+
223
+ # Returns a BSON document read from the socket.
224
+ # Returns nil if the operation times out or if a network
225
+ # connection failure occurs
226
+ def self.read_bson_document(socket)
227
+ bytebuf = BSON::ByteBuffer.new
228
+ # Read 4 byte size of following BSON document
229
+ bytes = socket.read(4)
230
+
231
+ # Read BSON document
232
+ sz = bytes.unpack("V")[0]
233
+ raise "Invalid Data received from server:#{bytes.inspect}" unless sz
234
+
235
+ bytebuf.append!(bytes)
236
+ bytebuf.append!(socket.read(sz - 4))
237
+ return BSON.deserialize(bytebuf)
238
+ end
239
+
240
+ # Returns a new connection pool for the specified server
241
+ def self.new_connection_pool(server, params={})
242
+ # Connection pool configuration options
243
+ config = pool_config.dup
244
+
245
+ # Method to call to close idle connections
246
+ config[:close_proc] = :close
247
+ config[:logger] = logger
248
+ config[:name] = "Connection pool for #{server}"
249
+
250
+ pool = GenePool.new(pool_config) do
251
+ new(server, params)
252
+ end
253
+
254
+ # Cleanup corresponding connection pool when a server terminates
255
+ Registry.on_server_removed(server) do
256
+ pool = @@connection_pools.delete(server)
257
+ # Cannot close all the connections since they could still be in use
258
+ pool.remove_idle(0) if pool
259
+ #pool.close if pool
260
+ logger.debug "Connection pool for server:#{server} has been released"
261
+ end
262
+
263
+ pool
264
+ end
265
+
266
+ end
267
+
268
+ end
269
+
@@ -142,14 +142,29 @@ module RubySkynet
142
142
  end
143
143
  end
144
144
 
145
- # TODO Implement watching for changes in a separate thread with it's own
146
- # client connection
147
- #def watch(path, rev = nil)
148
- # invoke(Request.new(:path => secret, :verb => Request::Verb::WAIT))
149
- #end
145
+ # Wait for changes to the supplied path
146
+ # Returns the next change to the supplied path
147
+ def wait(path, rev=current_revision, timeout=-1)
148
+ invoke(Request.new(:path => path, :rev => rev, :verb => Request::Verb::WAIT), true, timeout)
149
+ end
150
+
151
+ # Watch for any changes to the supplied path, calling the supplied block
152
+ # for every change
153
+ # Runs until an exception is thrown
154
+ #
155
+ # If a connection error occurs it will create a new connection to doozer
156
+ # and resubmit the wait. I.e. Will continue from where it left off
157
+ # without any noticeable effect to the supplied block
158
+ def watch(path, rev=current_revision)
159
+ loop do
160
+ result = wait(path, rev, -1)
161
+ yield result
162
+ rev = result.rev + 1
163
+ end
164
+ end
150
165
 
151
166
  #####################
152
- # protected
167
+ #protected
153
168
 
154
169
  # Call the Doozer server
155
170
  #
@@ -158,16 +173,16 @@ module RubySkynet
158
173
  # _only_ if a rev has been supplied
159
174
  #
160
175
  # When modifier is true
161
- def invoke(request, readonly=true)
176
+ def invoke(request, readonly=true, timeout=nil)
162
177
  retry_read = readonly || !request.rev.nil?
163
178
  response = nil
164
179
  @socket.retry_on_connection_failure do
165
180
  send(request)
166
- response = read if retry_read
181
+ response = read(timeout) if retry_read
167
182
  end
168
183
  # Network error on read must be sent back to caller since we do not
169
184
  # know if the modification was made
170
- response = read unless retry_read
185
+ response = read(timeout) unless retry_read
171
186
  raise ResponseError.new("#{Response::Err.name_by_value(response.err_code)}: #{response.err_detail}") if response.err_code != 0
172
187
  response
173
188
  end
@@ -182,19 +197,11 @@ module RubySkynet
182
197
  end
183
198
 
184
199
  # Read the protobuf Response from Doozer
185
- def read
200
+ def read(timeout=nil)
186
201
  # First strip the additional header indicating the size of the subsequent response
187
- head = @socket.read(4)
202
+ head = @socket.read(4,nil,timeout)
188
203
  length = head.unpack("N")[0]
189
-
190
- # Since can returns upto 'length' bytes we need to make sure it returns
191
- # at least 'length' bytes
192
- # TODO: Make this a binary buffer
193
- data = ''
194
- until data.size >= length
195
- data << @socket.read(length)
196
- end
197
- Response.new.parse_from_string(data)
204
+ Response.new.parse_from_string(@socket.read(length))
198
205
  end
199
206
 
200
207
  end
@@ -1,6 +1,7 @@
1
1
  module RubySkynet
2
2
  class Exception < ::RuntimeError; end
3
3
  class ProtocolError < Exception; end
4
- class SkynetException < Exception; end
5
4
  class ServiceException < Exception; end
5
+ class SkynetException < Exception; end
6
+ class ServiceUnavailable < SkynetException; end
6
7
  end
@@ -0,0 +1,252 @@
1
+ require 'sync_attr'
2
+ require 'multi_json'
3
+ require 'thread_safe'
4
+ require 'gene_pool'
5
+
6
+ #
7
+ # RubySkynet Registry Client
8
+ #
9
+ # Keeps a local copy of the Skynet Registry
10
+ #
11
+ # Subscribes to Registry changes and the internal copy up to date
12
+ #
13
+ module RubySkynet
14
+ class Registry
15
+ include SyncAttr
16
+
17
+ # Service Registry has the following format
18
+ # Key: [String] 'service_name/version/region'
19
+ # Value: [Array<String>] 'host:port', 'host:port'
20
+ sync_cattr_accessor :service_registry do
21
+ start_monitoring
22
+ end
23
+
24
+ @@on_server_removed_callbacks = ThreadSafe::Hash.new
25
+ @@monitor_thread = nil
26
+
27
+ DOOZER_SERVICES_PATH = "/services/*/*/*/*/*"
28
+
29
+ # Default doozer configuration
30
+ # To replace this default, set the config as follows:
31
+ # RubySkynet::Client.doozer_config = { .... }
32
+ #
33
+ # :servers [Array of String]
34
+ # Array of URL's of doozer servers to connect to with port numbers
35
+ # ['server1:2000', 'server2:2000']
36
+ #
37
+ # The second server will only be attempted once the first server
38
+ # cannot be connected to or has timed out on connect
39
+ # A read failure or timeout will not result in switching to the second
40
+ # server, only a connection failure or during an automatic reconnect
41
+ #
42
+ # :read_timeout [Float]
43
+ # Time in seconds to timeout on read
44
+ # Can be overridden by supplying a timeout in the read call
45
+ #
46
+ # :connect_timeout [Float]
47
+ # Time in seconds to timeout when trying to connect to the server
48
+ #
49
+ # :connect_retry_count [Fixnum]
50
+ # Number of times to retry connecting when a connection fails
51
+ #
52
+ # :connect_retry_interval [Float]
53
+ # Number of seconds between connection retry attempts after the first failed attempt
54
+ sync_cattr_accessor :doozer_config do
55
+ {
56
+ :servers => ['127.0.0.1:8046'],
57
+ :read_timeout => 5,
58
+ :connect_timeout => 3,
59
+ :connect_retry_interval => 1,
60
+ :connect_retry_count => 300
61
+ }
62
+ end
63
+
64
+ # Lazy initialize Doozer Client Connection pool
65
+ sync_cattr_reader :doozer_pool do
66
+ GenePool.new(
67
+ :name =>"Doozer Connection Pool",
68
+ :pool_size => 5,
69
+ :timeout => 30,
70
+ :warn_timeout => 5,
71
+ :idle_timeout => 600,
72
+ :logger => logger,
73
+ :close_proc => :close
74
+ ) do
75
+ Doozer::Client.new(doozer_config)
76
+ end
77
+ end
78
+
79
+ # Logging instance for this class
80
+ sync_cattr_reader :logger do
81
+ SemanticLogger::Logger.new(self, :debug)
82
+ end
83
+
84
+ # Return a server that implements the specified service
85
+ def self.server_for(service_name, version='*', region='Development')
86
+ if servers = servers_for(service_name, version, region)
87
+ # Randomly select one of the servers offering the service
88
+ servers[rand(servers.size)]
89
+ else
90
+ msg = "No servers available for service: #{service_name} with version: #{version} in region: #{region}"
91
+ logger.warn msg
92
+ raise ServiceUnavailable.new(msg)
93
+ end
94
+ end
95
+
96
+ # Returns [Array] of the hostname and port pair [String] that implements a particular service
97
+ # Performs a doozer lookup to find the servers
98
+ #
99
+ # service_name:
100
+ # Name of the service to lookup
101
+ # version:
102
+ # Version of service to locate
103
+ # Default: All versions
104
+ # region:
105
+ # Region to look for the service in
106
+ def self.registered_implementers(service_name='*', version='*', region='Development')
107
+ hosts = []
108
+ doozer_pool.with_connection do |doozer|
109
+ doozer.walk("/services/#{service_name}/#{version}/#{region}/*/*").each do |node|
110
+ entry = MultiJson.load(node.value)
111
+ hosts << entry if entry['Registered']
112
+ end
113
+ end
114
+ hosts
115
+ end
116
+
117
+ # Returns [Array<String>] a list of servers implementing the requested service
118
+ def self.servers_for(service_name, version='*', region='Development', remote = false)
119
+ if remote
120
+ if version != '*'
121
+ registered_implementers(service_name, version, region).map do |host|
122
+ service = host['Config']['ServiceAddr']
123
+ "#{service['IPAddress']}:#{service['Port']}"
124
+ end
125
+ else
126
+ # Find the highest version of any particular service
127
+ versions = {}
128
+ registered_implementers(service_name, version, region).each do |host|
129
+ service = host['Config']['ServiceAddr']
130
+ (versions[version.to_i] ||= []) << "#{service['IPAddress']}:#{service['Port']}"
131
+ end
132
+ # Return the servers implementing the highest version number
133
+ versions.sort.last.last
134
+ end
135
+ else
136
+ if version == '*'
137
+ # Find the highest version for the named service in this region
138
+ version = -1
139
+ service_registry.keys.each do |key|
140
+ if match = key.match(/#{service_name}\/(\d+)\/#{region}/)
141
+ ver = match[1].to_i
142
+ version = ver if ver > version
143
+ end
144
+ end
145
+ end
146
+ service_registry["#{service_name}/#{version}/#{region}"]
147
+ end
148
+ end
149
+
150
+ # Invokes registered callbacks when a specific server is shutdown or terminates
151
+ # Not when a server de-registers itself
152
+ # The callback will only be called once and will need to be re-registered
153
+ # after being called if future callbacks are required for that server
154
+ def self.on_server_removed(server, &block)
155
+ (@@on_server_removed_callbacks[server] ||= ThreadSafe::Array.new) << block
156
+ end
157
+
158
+ ############################
159
+ #protected
160
+
161
+ # Fetch the all registry information from Doozer and set the internal registry
162
+ # Also starts the monitoring thread to keep the registry up to date
163
+ def self.start_monitoring
164
+ registry = ThreadSafe::Hash.new
165
+ revision = nil
166
+ doozer_pool.with_connection do |doozer|
167
+ revision = doozer.current_revision
168
+ doozer.walk(DOOZER_SERVICES_PATH, revision).each do |node|
169
+ # path: "/services/TutorialService/1/Development/127.0.0.1/9000"
170
+ e = node.path.split('/')
171
+
172
+ # Key: [String] 'service_name/version/region'
173
+ key = "#{e[2]}/#{e[3]}/#{e[4]}"
174
+ server = "#{e[5]}:#{e[6]}"
175
+
176
+ if node.value.strip.size > 0
177
+ entry = MultiJson.load(node.value)
178
+ if entry['Registered']
179
+ # Value: [Array<String>] 'host:port', 'host:port'
180
+ servers = (registry[key] ||= ThreadSafe::Array.new)
181
+ servers << server unless servers.include?(server)
182
+ logger.debug "#start_monitoring Add Service: #{key} => #{server}"
183
+ end
184
+ end
185
+ end
186
+ end
187
+ # Start monitoring thread to keep the registry up to date
188
+ @@monitor_thread = Thread.new { self.watch(revision + 1) }
189
+ registry
190
+ end
191
+
192
+ # Waits for any updates from Doozer and updates the internal service registry
193
+ def self.watch(revision)
194
+ logger.info "Start monitoring #{DOOZER_SERVICES_PATH}"
195
+ # This thread must use its own dedicated doozer connection
196
+ doozer = Doozer::Client.new(doozer_config)
197
+ doozer.watch(DOOZER_SERVICES_PATH, revision) do |node|
198
+ # path: "/services/TutorialService/1/Development/127.0.0.1/9000"
199
+ e = node.path.split('/')
200
+
201
+ # Key: [String] 'service_name/version/region'
202
+ key = "#{e[2]}/#{e[3]}/#{e[4]}"
203
+ server = "#{e[5]}:#{e[6]}"
204
+
205
+ if node.value.strip.size > 0
206
+ entry = MultiJson.load(node.value)
207
+ if entry['Registered']
208
+ # Value: [Array<String>] 'host:port', 'host:port'
209
+ servers = (@@service_registry[key] ||= ThreadSafe::Array.new)
210
+ servers << server unless servers.include?(server)
211
+ logger.debug "#monitor Add/Update Service: #{key} => #{server}"
212
+ else
213
+ logger.debug "#monitor Service deregistered, remove: #{key} => #{server}"
214
+ if @@service_registry[key]
215
+ @@service_registry[key].delete(server)
216
+ @@service_registry.delete(key) if @@service_registry[key].size == 0
217
+ end
218
+ end
219
+ else
220
+ # Service has stopped and needs to be removed
221
+ logger.debug "#monitor Service stopped, remove: #{key} => #{server}"
222
+ if @@service_registry[key]
223
+ @@service_registry[key].delete(server)
224
+ @@service_registry.delete(key) if @@service_registry[key].size == 0
225
+ server_removed(server)
226
+ end
227
+ end
228
+ logger.debug "Updated registry", @@service_registry
229
+ end
230
+ logger.info "Stopping monitoring thread normally"
231
+ rescue Exception => exc
232
+ logger.error "Exception in monitoring thread", exc
233
+ ensure
234
+ logger.info "Stopped monitoring"
235
+ end
236
+
237
+ # Invoke any registered callbacks for the specific server
238
+ def self.server_removed(server)
239
+ if callbacks = @@on_server_removed_callbacks.delete(server)
240
+ callbacks.each do |block|
241
+ begin
242
+ logger.info "Calling callback for server: #{server}"
243
+ block.call(server)
244
+ rescue Exception => exc
245
+ logger.error("Exception during a callback for server: #{server}", exc)
246
+ end
247
+ end
248
+ end
249
+ end
250
+
251
+ end
252
+ end
@@ -1,3 +1,3 @@
1
1
  module RubySkynet #:nodoc
2
- VERSION = "0.1.2"
2
+ VERSION = "0.2.0"
3
3
  end
data/lib/ruby_skynet.rb CHANGED
@@ -7,5 +7,7 @@ module RubySkynet
7
7
  module Doozer
8
8
  autoload :Client, 'ruby_skynet/doozer/client'
9
9
  end
10
- autoload :Client, 'ruby_skynet/client'
10
+ autoload :Registry, 'ruby_skynet/registry'
11
+ autoload :Connection, 'ruby_skynet/connection'
12
+ autoload :Client, 'ruby_skynet/client'
11
13
  end
@@ -7,6 +7,7 @@ require 'test/unit'
7
7
  require 'shoulda'
8
8
  require 'ruby_skynet'
9
9
  require 'simple_server'
10
+ require 'multi_json'
10
11
 
11
12
  SemanticLogger::Logger.default_level = :trace
12
13
  SemanticLogger::Logger.appenders << SemanticLogger::Appender::File.new('test.log')
@@ -17,43 +18,57 @@ class RubySkynetClientTest < Test::Unit::TestCase
17
18
 
18
19
  context "without server" do
19
20
  should "raise exception when cannot reach server after 5 retries" do
20
- exception = assert_raise ResilientSocket::ConnectionFailure do
21
- RubySkynet::Client.new('SomeService',
22
- :server => 'localhost:3300',
23
- :connect_retry_interval => 0.1,
24
- :connect_retry_count => 5)
21
+ exception = assert_raise RubySkynet::ServiceUnavailable do
22
+ client = RubySkynet::Client.new('SomeService')
23
+ client.call(:test, :hello => 'there')
25
24
  end
26
- assert_match /After 5 connection attempts to host 'localhost:3300': Errno::ECONNREFUSED/, exception.message
25
+ assert_match /No servers available for service: SomeService with version: \* in region: Development/, exception.message
27
26
  end
28
27
 
29
28
  end
30
29
 
31
30
  context "with server" do
32
31
  setup do
32
+ @port = 2000
33
33
  @read_timeout = 3.0
34
- @server = SimpleServer.new(2000)
35
- @server_name = 'localhost:2000'
34
+ @server = SimpleServer.new(@port)
35
+ @server_name = "localhost:#{@port}"
36
+
37
+ # Register service in doozer
38
+ @service_name = "TestService"
39
+ @version = 1
40
+ @region = 'Test'
41
+ @ip_address = "127.0.0.1"
42
+ config = {
43
+ "Config" => {
44
+ "UUID" => "3978b371-15e9-40f8-9b7b-59ae88d8c7ec",
45
+ "Name" => @service_name,
46
+ "Version" => @version.to_s,
47
+ "Region" => @region,
48
+ "ServiceAddr" => {
49
+ "IPAddress" => @ip_address,
50
+ "Port" => @port,
51
+ "MaxPort" => @port + 999
52
+ },
53
+ },
54
+ "Registered" => true
55
+ }
56
+ RubySkynet::Registry.doozer_pool.with_connection do |doozer|
57
+ doozer["/services/#{@service_name}/#{@version}/#{@region}/#{@ip_address}/#{@port}"] = MultiJson.encode(config)
58
+ end
36
59
  end
37
60
 
38
61
  teardown do
39
62
  @server.stop if @server
40
- end
41
-
42
- context "using blocks" do
43
- should "call server" do
44
- RubySkynet::Client.connect('TutorialService', :read_timeout => @read_timeout, :server => @server_name) do |tutorial_service|
45
- assert_equal 'test1', tutorial_service.call(:test1, 'some' => 'parameters')['result']
46
- end
63
+ # De-register server in doozer
64
+ RubySkynet::Registry.doozer_pool.with_connection do |doozer|
65
+ doozer.delete("/services/#{@service_name}/#{@version}/#{@region}/#{@ip_address}/#{@port}") rescue nil
47
66
  end
48
67
  end
49
68
 
50
69
  context "with client connection" do
51
70
  setup do
52
- @client = RubySkynet::Client.new('TutorialService', :read_timeout => @read_timeout, :server => @server_name)
53
- end
54
-
55
- def teardown
56
- @client.close if @client
71
+ @client = RubySkynet::Client.new(@service_name, @version, @region)
57
72
  end
58
73
 
59
74
  should "successfully send and receive data" do
@@ -66,7 +81,7 @@ class RubySkynetClientTest < Test::Unit::TestCase
66
81
 
67
82
  exception = assert_raise ResilientSocket::ReadTimeout do
68
83
  # Read 4 bytes from server
69
- @client.call('sleep', request)
84
+ @client.call('sleep', request, :read_timeout => @read_timeout)
70
85
  end
71
86
  assert_match /Timedout after #{@read_timeout} seconds trying to read/, exception.message
72
87
  end
data/test.log CHANGED
Binary file
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ruby_skynet
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.2
4
+ version: 0.2.0
5
5
  prerelease:
6
6
  platform: ruby
7
7
  authors:
@@ -9,7 +9,7 @@ authors:
9
9
  autorequire:
10
10
  bindir: bin
11
11
  cert_chain: []
12
- date: 2012-10-15 00:00:00.000000000 Z
12
+ date: 2012-10-18 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: semantic_logger
@@ -98,13 +98,16 @@ executables: []
98
98
  extensions: []
99
99
  extra_rdoc_files: []
100
100
  files:
101
+ - dev.log
101
102
  - Gemfile
102
103
  - Gemfile.lock
103
104
  - lib/ruby_skynet/client.rb
105
+ - lib/ruby_skynet/connection.rb
104
106
  - lib/ruby_skynet/doozer/client.rb
105
107
  - lib/ruby_skynet/doozer/exceptions.rb
106
108
  - lib/ruby_skynet/doozer/msg.pb.rb
107
109
  - lib/ruby_skynet/exceptions.rb
110
+ - lib/ruby_skynet/registry.rb
108
111
  - lib/ruby_skynet/version.rb
109
112
  - lib/ruby_skynet.rb
110
113
  - LICENSE.txt
@@ -116,8 +119,6 @@ files:
116
119
  - nbproject/project.xml
117
120
  - Rakefile
118
121
  - README.md
119
- - ruby_skynet-0.1.0.gem
120
- - ruby_skynet-0.1.1.gem
121
122
  - test/doozer_client_test.rb
122
123
  - test/ruby_skynet_client_test.rb
123
124
  - test/simple_server.rb
@@ -136,7 +137,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
136
137
  version: '0'
137
138
  segments:
138
139
  - 0
139
- hash: 4150278993110980832
140
+ hash: -4330021218658134009
140
141
  required_rubygems_version: !ruby/object:Gem::Requirement
141
142
  none: false
142
143
  requirements:
Binary file
Binary file