georgi-nsq-ruby 2.1.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.
@@ -0,0 +1,120 @@
1
+ require_relative 'discovery'
2
+ require_relative 'connection'
3
+ require_relative 'logger'
4
+
5
+ module Nsq
6
+ class ClientBase
7
+ include Nsq::AttributeLogger
8
+ @@log_attributes = [:topic]
9
+
10
+ attr_reader :topic
11
+ attr_reader :connections
12
+
13
+ def connected?
14
+ @connections.values.any?(&:connected?)
15
+ end
16
+
17
+
18
+ def terminate
19
+ @discovery_thread.kill if @discovery_thread
20
+ drop_all_connections
21
+ end
22
+
23
+
24
+ private
25
+
26
+ # discovers nsqds from an nsqlookupd repeatedly
27
+ #
28
+ # opts:
29
+ # nsqlookups: ['127.0.0.1:4161'],
30
+ # topic: 'topic-to-find-nsqds-for',
31
+ # interval: 60
32
+ #
33
+ def discover_repeatedly(opts = {})
34
+ @discovery_thread = Thread.new do
35
+
36
+ @discovery = Discovery.new(opts[:nsqlookupds])
37
+
38
+ loop do
39
+ begin
40
+ nsqds = nsqds_from_lookupd(opts[:topic])
41
+ drop_and_add_connections(nsqds)
42
+ rescue DiscoveryException
43
+ # We can't connect to any nsqlookupds. That's okay, we'll just
44
+ # leave our current nsqd connections alone and try again later.
45
+ warn 'Could not connect to any nsqlookupd instances in discovery loop'
46
+ end
47
+ sleep opts[:interval]
48
+ end
49
+
50
+ end
51
+
52
+ @discovery_thread.abort_on_exception = true
53
+ end
54
+
55
+
56
+ def nsqds_from_lookupd(topic = nil)
57
+ if topic
58
+ @discovery.nsqds_for_topic(topic)
59
+ else
60
+ @discovery.nsqds
61
+ end
62
+ end
63
+
64
+
65
+ def drop_and_add_connections(nsqds)
66
+ # drop nsqd connections that are no longer in lookupd
67
+ missing_nsqds = @connections.keys - nsqds
68
+ missing_nsqds.each do |nsqd|
69
+ drop_connection(nsqd)
70
+ end
71
+
72
+ # add new ones
73
+ new_nsqds = nsqds - @connections.keys
74
+ new_nsqds.each do |nsqd|
75
+ begin
76
+ add_connection(nsqd)
77
+ rescue Exception => ex
78
+ error "Failed to connect to nsqd @ #{nsqd}: #{ex}"
79
+ end
80
+ end
81
+
82
+ # balance RDY state amongst the connections
83
+ connections_changed
84
+ end
85
+
86
+
87
+ def add_connection(nsqd, options = {})
88
+ info "+ Adding connection #{nsqd}"
89
+ host, port = nsqd.split(':')
90
+ connection = Connection.new({
91
+ host: host,
92
+ port: port,
93
+ ssl_context: @ssl_context,
94
+ tls_options: @tls_options,
95
+ tls_v1: @tls_v1
96
+ }.merge(options))
97
+ @connections[nsqd] = connection
98
+ end
99
+
100
+
101
+ def drop_connection(nsqd)
102
+ info "- Dropping connection #{nsqd}"
103
+ connection = @connections.delete(nsqd)
104
+ connection.close if connection
105
+ connections_changed
106
+ end
107
+
108
+
109
+ def drop_all_connections
110
+ @connections.keys.each do |nsqd|
111
+ drop_connection(nsqd)
112
+ end
113
+ end
114
+
115
+
116
+ # optional subclass hook
117
+ def connections_changed
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,461 @@
1
+ require 'json'
2
+ require 'socket'
3
+ require 'openssl'
4
+ require 'timeout'
5
+
6
+ require_relative 'frames/error'
7
+ require_relative 'frames/message'
8
+ require_relative 'frames/response'
9
+ require_relative 'logger'
10
+
11
+ module Nsq
12
+ class Connection
13
+ include Nsq::AttributeLogger
14
+ @@log_attributes = [:host, :port]
15
+
16
+ attr_reader :host
17
+ attr_reader :port
18
+ attr_accessor :max_in_flight
19
+ attr_reader :presumed_in_flight
20
+
21
+ USER_AGENT = "nsq-ruby/#{Nsq::Version::STRING}"
22
+ RESPONSE_HEARTBEAT = '_heartbeat_'
23
+ RESPONSE_OK = 'OK'
24
+
25
+
26
+ def initialize(opts = {})
27
+ @host = opts[:host] || (raise ArgumentError, 'host is required')
28
+ @port = opts[:port] || (raise ArgumentError, 'port is required')
29
+ @queue = opts[:queue]
30
+ @topic = opts[:topic]
31
+ @channel = opts[:channel]
32
+ @msg_timeout = opts[:msg_timeout] || 60_000 # 60s
33
+ @max_in_flight = opts[:max_in_flight] || 1
34
+ @tls_options = opts[:tls_options]
35
+ if opts[:ssl_context]
36
+ if @tls_options
37
+ warn 'ssl_context and tls_options both set. Using tls_options. Ignoring ssl_context.'
38
+ else
39
+ @tls_options = opts[:ssl_context]
40
+ warn 'ssl_context will be deprecated nsq-ruby version 3. Please use tls_options instead.'
41
+ end
42
+ end
43
+ @tls_v1 = !!opts[:tls_v1]
44
+
45
+ if @tls_options
46
+ if @tls_v1
47
+ validate_tls_options!
48
+ else
49
+ warn 'tls_options was provided, but tls_v1 is false. Skipping validation of tls_options.'
50
+ end
51
+ end
52
+
53
+ if @msg_timeout < 1000
54
+ raise ArgumentError, 'msg_timeout cannot be less than 1000. it\'s in milliseconds.'
55
+ end
56
+
57
+ # for outgoing communication
58
+ @write_queue = Queue.new
59
+
60
+ # For indicating that the connection has died.
61
+ # We use a Queue so we don't have to poll. Used to communicate across
62
+ # threads (from write_loop and read_loop to connect_and_monitor).
63
+ @death_queue = Queue.new
64
+
65
+ @connected = false
66
+ @presumed_in_flight = 0
67
+
68
+ open_connection
69
+ start_monitoring_connection
70
+ end
71
+
72
+
73
+ def connected?
74
+ @connected
75
+ end
76
+
77
+
78
+ # close the connection and don't try to re-open it
79
+ def close
80
+ stop_monitoring_connection
81
+ close_connection
82
+ end
83
+
84
+
85
+ def sub(topic, channel)
86
+ write "SUB #{topic} #{channel}\n"
87
+ end
88
+
89
+
90
+ def rdy(count)
91
+ write "RDY #{count}\n"
92
+ end
93
+
94
+
95
+ def fin(message_id)
96
+ write "FIN #{message_id}\n"
97
+ decrement_in_flight
98
+ end
99
+
100
+
101
+ def req(message_id, timeout)
102
+ write "REQ #{message_id} #{timeout}\n"
103
+ decrement_in_flight
104
+ end
105
+
106
+
107
+ def touch(message_id)
108
+ write "TOUCH #{message_id}\n"
109
+ end
110
+
111
+
112
+ def pub(topic, message)
113
+ write ["PUB #{topic}\n", message.bytesize, message].pack('a*l>a*')
114
+ end
115
+
116
+ def dpub(topic, delay_in_ms, message)
117
+ write ["DPUB #{topic} #{delay_in_ms}\n", message.bytesize, message].pack('a*l>a*')
118
+ end
119
+
120
+ def mpub(topic, messages)
121
+ body = messages.map do |message|
122
+ [message.bytesize, message].pack('l>a*')
123
+ end.join
124
+
125
+ write ["MPUB #{topic}\n", body.bytesize, messages.size, body].pack('a*l>l>a*')
126
+ end
127
+
128
+
129
+ # Tell the server we are ready for more messages!
130
+ def re_up_ready
131
+ rdy(@max_in_flight)
132
+ # assume these messages are coming our way. yes, this might not be the
133
+ # case, but it's much easier to manage our RDY state with the server if
134
+ # we treat things this way.
135
+ @presumed_in_flight = @max_in_flight
136
+ end
137
+
138
+
139
+ private
140
+
141
+ def cls
142
+ write "CLS\n"
143
+ end
144
+
145
+
146
+ def nop
147
+ write "NOP\n"
148
+ end
149
+
150
+
151
+ def write(raw)
152
+ @write_queue.push(raw)
153
+ end
154
+
155
+
156
+ def write_to_socket(raw)
157
+ debug ">>> #{raw.inspect}"
158
+ @socket.write(raw)
159
+ end
160
+
161
+
162
+ def identify
163
+ hostname = Socket.gethostname
164
+ metadata = {
165
+ client_id: hostname,
166
+ hostname: hostname,
167
+ feature_negotiation: true,
168
+ heartbeat_interval: 30_000, # 30 seconds
169
+ output_buffer: 16_000, # 16kb
170
+ output_buffer_timeout: 250, # 250ms
171
+ tls_v1: @tls_v1,
172
+ snappy: false,
173
+ deflate: false,
174
+ sample_rate: 0, # disable sampling
175
+ user_agent: USER_AGENT,
176
+ msg_timeout: @msg_timeout
177
+ }.to_json
178
+ write_to_socket ["IDENTIFY\n", metadata.length, metadata].pack('a*l>a*')
179
+
180
+ # Now wait for the response!
181
+ frame = receive_frame
182
+ server = JSON.parse(frame.data)
183
+
184
+ if @max_in_flight > server['max_rdy_count']
185
+ raise "max_in_flight is set to #{@max_in_flight}, server only supports #{server['max_rdy_count']}"
186
+ end
187
+
188
+ @server_version = server['version']
189
+ end
190
+
191
+
192
+ def handle_response(frame)
193
+ if frame.data == RESPONSE_HEARTBEAT
194
+ debug 'Received heartbeat'
195
+ nop
196
+ elsif frame.data == RESPONSE_OK
197
+ debug 'Received OK'
198
+ else
199
+ die "Received response we don't know how to handle: #{frame.data}"
200
+ end
201
+ end
202
+
203
+
204
+ def receive_frame
205
+ if buffer = @socket.read(8)
206
+ size, type = buffer.unpack('l>l>')
207
+ size -= 4 # we want the size of the data part and type already took up 4 bytes
208
+ data = @socket.read(size)
209
+ frame_class = frame_class_for_type(type)
210
+ return frame_class.new(data, self)
211
+ end
212
+ end
213
+
214
+
215
+ FRAME_CLASSES = [Response, Error, Message]
216
+ def frame_class_for_type(type)
217
+ raise "Bad frame type specified: #{type}" if type > FRAME_CLASSES.length - 1
218
+ [Response, Error, Message][type]
219
+ end
220
+
221
+
222
+ def decrement_in_flight
223
+ @presumed_in_flight -= 1
224
+
225
+ if server_needs_rdy_re_ups?
226
+ # now that we're less than @max_in_flight we might need to re-up our RDY state
227
+ threshold = (@max_in_flight * 0.2).ceil
228
+ re_up_ready if @presumed_in_flight <= threshold
229
+ end
230
+ end
231
+
232
+
233
+ def start_read_loop
234
+ @read_loop_thread ||= Thread.new{read_loop}
235
+ end
236
+
237
+
238
+ def stop_read_loop
239
+ @read_loop_thread.kill if @read_loop_thread
240
+ @read_loop_thread = nil
241
+ end
242
+
243
+
244
+ def read_loop
245
+ loop do
246
+ frame = receive_frame
247
+ if frame.is_a?(Response)
248
+ handle_response(frame)
249
+ elsif frame.is_a?(Error)
250
+ error "Error received: #{frame.data}"
251
+ elsif frame.is_a?(Message)
252
+ debug "<<< #{frame.body}"
253
+ @queue.push(frame) if @queue
254
+ else
255
+ raise 'No data from socket'
256
+ end
257
+ end
258
+ rescue Exception => ex
259
+ die(ex)
260
+ end
261
+
262
+
263
+ def start_write_loop
264
+ @write_loop_thread ||= Thread.new{write_loop}
265
+ end
266
+
267
+
268
+ def stop_write_loop
269
+ if @write_loop_thread
270
+ @write_queue.push(:stop_write_loop)
271
+ @write_loop_thread.join
272
+ end
273
+ @write_loop_thread = nil
274
+ end
275
+
276
+
277
+ def write_loop
278
+ data = nil
279
+ loop do
280
+ data = @write_queue.pop
281
+ break if data == :stop_write_loop
282
+ write_to_socket(data)
283
+ end
284
+ rescue Exception => ex
285
+ # requeue PUB and MPUB commands
286
+ if data =~ /^M?PUB/
287
+ debug "Requeueing to write_queue: #{data.inspect}"
288
+ @write_queue.push(data)
289
+ end
290
+ die(ex)
291
+ end
292
+
293
+
294
+ # Waits for death of connection
295
+ def start_monitoring_connection
296
+ @connection_monitor_thread ||= Thread.new{monitor_connection}
297
+ @connection_monitor_thread.abort_on_exception = true
298
+ end
299
+
300
+
301
+ def stop_monitoring_connection
302
+ @connection_monitor_thread.kill if @connection_monitor_thread
303
+ @connection_monitor = nil
304
+ end
305
+
306
+
307
+ def monitor_connection
308
+ loop do
309
+ # wait for death, hopefully it never comes
310
+ cause_of_death = @death_queue.pop
311
+ warn "Died from: #{cause_of_death}"
312
+
313
+ debug 'Reconnecting...'
314
+ reconnect
315
+ debug 'Reconnected!'
316
+
317
+ # clear all death messages, since we're now reconnected.
318
+ # we don't want to complete this loop and immediately reconnect again.
319
+ @death_queue.clear
320
+ end
321
+ end
322
+
323
+
324
+ # close the connection if it's not already closed and try to reconnect
325
+ # over and over until we succeed!
326
+ def reconnect
327
+ close_connection
328
+ with_retries do
329
+ open_connection
330
+ end
331
+ end
332
+
333
+
334
+ def open_connection
335
+ @socket = TCPSocket.new(@host, @port)
336
+ # write the version and IDENTIFY directly to the socket to make sure
337
+ # it gets to nsqd ahead of anything in the `@write_queue`
338
+ write_to_socket ' V2'
339
+ identify
340
+ upgrade_to_ssl_socket if @tls_v1
341
+
342
+ start_read_loop
343
+ start_write_loop
344
+ @connected = true
345
+
346
+ # we need to re-subscribe if there's a topic specified
347
+ if @topic
348
+ debug "Subscribing to #{@topic}"
349
+ sub(@topic, @channel)
350
+ re_up_ready
351
+ end
352
+ end
353
+
354
+
355
+ # closes the connection and stops listening for messages
356
+ def close_connection
357
+ cls if connected?
358
+ stop_read_loop
359
+ stop_write_loop
360
+ @socket.close if @socket
361
+ @socket = nil
362
+ @connected = false
363
+ end
364
+
365
+
366
+ # this is called when there's a connection error in the read or write loop
367
+ # it triggers `connect_and_monitor` to try to reconnect
368
+ def die(reason)
369
+ @connected = false
370
+ @death_queue.push(reason)
371
+ end
372
+
373
+
374
+ def upgrade_to_ssl_socket
375
+ ssl_opts = [@socket, openssl_context].compact
376
+ @socket = OpenSSL::SSL::SSLSocket.new(*ssl_opts)
377
+ @socket.sync_close = true
378
+ @socket.connect
379
+ end
380
+
381
+
382
+ def openssl_context
383
+ return unless @tls_options
384
+
385
+ context = OpenSSL::SSL::SSLContext.new
386
+ context.cert = OpenSSL::X509::Certificate.new(File.read(@tls_options[:certificate]))
387
+ context.key = OpenSSL::PKey::RSA.new(File.read(@tls_options[:key]))
388
+ if @tls_options[:ca_certificate]
389
+ context.ca_file = @tls_options[:ca_certificate]
390
+ end
391
+ context.verify_mode = @tls_options[:verify_mode] || OpenSSL::SSL::VERIFY_NONE
392
+ context
393
+ end
394
+
395
+
396
+ # Retry the supplied block with exponential backoff.
397
+ #
398
+ # Borrowed liberally from:
399
+ # https://github.com/ooyala/retries/blob/master/lib/retries.rb
400
+ def with_retries(&block)
401
+ base_sleep_seconds = 0.5
402
+ max_sleep_seconds = 300 # 5 minutes
403
+
404
+ # Let's do this thing
405
+ attempts = 0
406
+
407
+ begin
408
+ attempts += 1
409
+ return block.call(attempts)
410
+
411
+ rescue Errno::ECONNREFUSED, Errno::ECONNRESET, Errno::EHOSTUNREACH,
412
+ Errno::ENETDOWN, Errno::ENETUNREACH, Errno::ETIMEDOUT, Timeout::Error => ex
413
+
414
+ raise ex if attempts >= 100
415
+
416
+ # The sleep time is an exponentially-increasing function of base_sleep_seconds.
417
+ # But, it never exceeds max_sleep_seconds.
418
+ sleep_seconds = [base_sleep_seconds * (2 ** (attempts - 1)), max_sleep_seconds].min
419
+ # Randomize to a random value in the range sleep_seconds/2 .. sleep_seconds
420
+ sleep_seconds = sleep_seconds * (0.5 * (1 + rand()))
421
+ # But never sleep less than base_sleep_seconds
422
+ sleep_seconds = [base_sleep_seconds, sleep_seconds].max
423
+
424
+ warn "Failed to connect: #{ex}. Retrying in #{sleep_seconds.round(1)} seconds."
425
+
426
+ snooze(sleep_seconds)
427
+
428
+ retry
429
+ end
430
+ end
431
+
432
+
433
+ # Se we can stub for testing and reconnect in a tight loop
434
+ def snooze(t)
435
+ sleep(t)
436
+ end
437
+
438
+
439
+ def server_needs_rdy_re_ups?
440
+ # versions less than 0.3.0 need RDY re-ups
441
+ # see: https://github.com/bitly/nsq/blob/master/ChangeLog.md#030---2014-11-18
442
+ major, minor = @server_version.split('.').map(&:to_i)
443
+ major == 0 && minor <= 2
444
+ end
445
+
446
+
447
+ def validate_tls_options!
448
+ [:key, :certificate].each do |key|
449
+ unless @tls_options.has_key?(key)
450
+ raise ArgumentError.new "@tls_options requires a :#{key}"
451
+ end
452
+ end
453
+
454
+ [:key, :certificate, :ca_certificate].each do |key|
455
+ if @tls_options[key] && !File.readable?(@tls_options[key])
456
+ raise LoadError.new "@tls_options :#{key} is unreadable"
457
+ end
458
+ end
459
+ end
460
+ end
461
+ end