ruby_skynet 0.1.2 → 0.2.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.
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