nsq-ruby 0.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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 03b1479ff830b7fa68dcbccaab0e2b9a9866b433
4
+ data.tar.gz: 3df0a88a0c40d24c252e891e3d054a3278213492
5
+ SHA512:
6
+ metadata.gz: 7548b29979d49e8b22d74b93a91b1f45eda87ef57d6211d6f4b8dac0fbe44c54d498c3a918a2d914e0cb3073abd63e537b104f0ee87b4540b4f04b57c2a6db4e
7
+ data.tar.gz: 5d15b617fd62fd51deb3b4902e51a1d60537c30b87fd0e36f76fc49e78329d69c3e8ab83e282d66853844447898d02f827134bf480bc57c4dd039e8bb03e0d08
data/LICENSE.txt ADDED
@@ -0,0 +1,22 @@
1
+ Copyright (c) 2014 Wistia, Inc.
2
+
3
+ MIT License
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining
6
+ a copy of this software and associated documentation files (the
7
+ "Software"), to deal in the Software without restriction, including
8
+ without limitation the rights to use, copy, modify, merge, publish,
9
+ distribute, sublicense, and/or sell copies of the Software, and to
10
+ permit persons to whom the Software is furnished to do so, subject to
11
+ the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be
14
+ included in all copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
20
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
21
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
22
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,276 @@
1
+ # nsq-ruby
2
+
3
+ nsq-ruby is a simple NSQ client library written in Ruby.
4
+
5
+ - The code is straightforward.
6
+ - It has no dependencies.
7
+ - It's well tested.
8
+
9
+
10
+ ## Quick start
11
+
12
+ ### Publish messages
13
+
14
+ ```Ruby
15
+ require 'nsq'
16
+ producer = Nsq::Producer.new(
17
+ nsqd: '127.0.0.1:4150',
18
+ topic: 'some-topic'
19
+ )
20
+
21
+ # Write a message to NSQ
22
+ producer.write('some-message')
23
+
24
+ # Write a bunch of messages to NSQ (uses mpub)
25
+ producer.write('one', 'two', 'three', 'four', 'five')
26
+
27
+ # Close the connection
28
+ producer.terminate
29
+ ```
30
+
31
+ ### Consume messages
32
+
33
+ ```Ruby
34
+ require 'nsq'
35
+ consumer = Nsq::Consumer.new(
36
+ nsqlookupd: '127.0.0.1:4161',
37
+ topic: 'some-topic',
38
+ channel: 'some-channel'
39
+ )
40
+
41
+ # Pop a message off the queue
42
+ msg = consumer.pop
43
+ puts msg.body
44
+ msg.finish
45
+
46
+ # Close the connections
47
+ consumer.terminate
48
+ ```
49
+
50
+
51
+ ## Producer
52
+
53
+ ### Instantialization
54
+
55
+ The Nsq::Producer constructor takes the following options:
56
+
57
+ | Option | Description | Default |
58
+ |---------------|----------------------------------------|--------------------|
59
+ | `topic` | Topic to which to publish messages | |
60
+ | `nsqd` | Host and port of the nsqd instance | '127.0.0.1:4150' |
61
+
62
+ For example:
63
+
64
+ ```Ruby
65
+ producer = Nsq::Producer.new(
66
+ nsqd: '6.7.8.9:4150',
67
+ topic: 'topic-of-great-esteem'
68
+ )
69
+ ```
70
+
71
+ ### `#write`
72
+
73
+ Publishes one or more message to nsqd. If you give it a single argument, it will
74
+ send it to nsqd via `PUB`. If you give it multiple arguments, it will send all
75
+ those messages to nsqd via `MPUB`. It will automatically call `to_s` on any
76
+ arguments you give it.
77
+
78
+ ```Ruby
79
+ # Send a single message via PUB
80
+ producer.write(123)
81
+
82
+ # Send three messages via MPUB
83
+ producer.write(456, 'another-message', { key: 'value' }.to_json)
84
+ ```
85
+
86
+ If it's connection to nsqd fails, it will automatically try to reconnect with
87
+ exponential backoff. Any messages that were sent to `#write` will be queued
88
+ and transmitted after reconnecting.
89
+
90
+ **Note** we don't wait for nsqd to acknowledge our writes. As a result, if the
91
+ connection to nsqd fails, you can lose messages. This is acceptable for our use
92
+ cases, mostly because we are sending messages to a local nsqd instance and
93
+ failure is very rare.
94
+
95
+ ### `#connected?`
96
+
97
+ Returns true if it's currently connected to nsqd and false if not.
98
+
99
+ ### `#terminate`
100
+
101
+ Closes the connection to nsqd and stops it from trying to automatically
102
+ reconnect.
103
+
104
+ This is automatically called `at_exit`, but it's good practice to close your
105
+ producers when you're done with them.
106
+
107
+
108
+ ## Consumer
109
+
110
+ ### Instantialization
111
+
112
+ | Option | Description | Default |
113
+ |----------------------|-----------------------------------------------|--------------------|
114
+ | `topic` | Topic to consume messages from | |
115
+ | `channel` | Channel name for this consumer | |
116
+ | `nsqlookupd` | Use lookupd to automatically discover nsqds | |
117
+ | `nsqd` | Connect directly to a single nsqd instance | '127.0.0.1:4150' |
118
+ | `max_in_flight` | Max number of messages for this consumer to have in flight at a time | 1 |
119
+ | `discovery_interval` | Seconds between queue discovery via nsqlookupd | 60.0 |
120
+ | `msg_timeout` | Milliseconds before nsqd will timeout a message | 60000 |
121
+
122
+
123
+ For example:
124
+
125
+ ```Ruby
126
+ consumer = Nsq::Consumer.new(
127
+ topic: 'the-topic',
128
+ channel: 'my-channel',
129
+ nsqlookupd: ['127.0.0.1:4161', '4.5.6.7:4161'],
130
+ max_in_flight: 100,
131
+ discovery_interval: 30,
132
+ msq_timeout: 120_000
133
+ )
134
+ ```
135
+
136
+ Notes:
137
+
138
+ - `nsqlookupd` can be a string or array of strings for each nsqlookupd service
139
+ you'd like to use. The format is `"<host>:<http-port>"`. If you specify
140
+ `nsqlookupd`, it ignores the `nsqd` option.
141
+ - `max_in_flight` is for the total max in flight across all the connections,
142
+ but to make the implementation of `nsq-ruby` as simple as possible, the minimum
143
+ `max_in_flight` _per_ connection is 1. So if you set `max_in_flight` to 1 and
144
+ are connected to 3 nsqds, you may have up to 3 messages in flight at a time.
145
+
146
+
147
+ ### `#pop`
148
+
149
+ `nsq-ruby` works by maintaining a local queue of in flight messages from NSQ.
150
+ To get at these messages, just call pop.
151
+
152
+ ```Ruby
153
+ message = consumer.pop
154
+ ```
155
+
156
+ If there are messages on the queue, `pop` will return one immediately. If there
157
+ are no messages on the queue, `pop` will block execution until one arrives.
158
+
159
+
160
+ ### `#size`
161
+
162
+ `size` returns the size of the local message queue.
163
+
164
+
165
+ ### `#terminate`
166
+
167
+ Gracefully closes all connections and stops the consumer. You should call this
168
+ when you're finished with a consumer object.
169
+
170
+
171
+ ## Message
172
+
173
+ The `Message` object is what you get when you call `pop` on a consumer.
174
+ Once you have a message, you'll likely want to get its contents using the `#body`
175
+ method, and then call `#finish` once you're done with it.
176
+
177
+ ### `body`
178
+
179
+ Returns the body of the message as a UTF-8 encoded string.
180
+
181
+ ### `attempts`
182
+
183
+ Returns the number of times this message was attempted to be processed. For
184
+ most messages this should be 1 (since it will be your first attempt processing
185
+ them). If it's more than 1, that means that you requeued the message or it
186
+ timed out in flight.
187
+
188
+ ### `#finish`
189
+
190
+ Notify NSQ that you've completed processing of this message.
191
+
192
+ ### `#touch`
193
+
194
+ Tells NSQ to reset the message timeout for this message so you have more time
195
+ to process it.
196
+
197
+ ### `#requeue(timeout = 0)`
198
+
199
+ Tells NSQ to requeue this message. Called with no arguments, this will requeue
200
+ the message and it will be available to be received immediately.
201
+
202
+ Optionally you can pass a number of milliseconds as an argument. This tells
203
+ NSQ to delay its requeueing by that number of milliseconds.
204
+
205
+
206
+ ## Logging
207
+
208
+ By default, `nsq-ruby` doesn't log anything. To enable logging, use
209
+ `Nsq.logger=` and point it at a Ruby Logger instance. Like this:
210
+
211
+ ```Ruby
212
+ Nsq.logger = Logger.new(STDOUT)
213
+ ```
214
+
215
+
216
+ ## Requirements
217
+
218
+ NSQ v0.2.29 or later (due to IDENTITY metadata specification (0.2.28 and per-
219
+ connection timeout support (0.2.29).
220
+
221
+
222
+ ### Supports
223
+
224
+ - Discovery via nsqlookupd
225
+ - Automatic reconnection to nsqd
226
+
227
+ ### Does not support
228
+
229
+ - TLS
230
+ - Compression
231
+ - Backoff
232
+ - Authentication
233
+
234
+ If you need more advanced features, like these, you should check out
235
+ [Krakow](https://github.com/chrisroberts/krakow), a more fully featured NSQ
236
+ client for Ruby.
237
+
238
+
239
+ ## Testing
240
+
241
+ Run the tests like this:
242
+
243
+ ```
244
+ rake spec
245
+ ```
246
+
247
+ Want a deluge of logging while running the specs to help determine what is
248
+ going on?
249
+
250
+ ```
251
+ VERBOSE=true rake spec
252
+ ```
253
+
254
+
255
+ ## MIT License
256
+
257
+ Copyright (C) 2014 Wistia, Inc.
258
+
259
+ Permission is hereby granted, free of charge, to any person obtaining a copy of
260
+ this software and associated documentation files (the "Software"), to deal in
261
+ the Software without restriction, including without limitation the rights to
262
+ use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
263
+ of the Software, and to permit persons to whom the Software is furnished to do
264
+ so, subject to the following conditions:
265
+
266
+ The above copyright notice and this permission notice shall be included in all
267
+ copies or substantial portions of the Software.
268
+
269
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
270
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
271
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
272
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
273
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
274
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
275
+ SOFTWARE.
276
+
data/lib/nsq.rb ADDED
@@ -0,0 +1,11 @@
1
+ require_relative 'version'
2
+
3
+ require_relative 'nsq/logger'
4
+
5
+ require_relative 'nsq/frames/frame'
6
+ require_relative 'nsq/frames/error'
7
+ require_relative 'nsq/frames/response'
8
+ require_relative 'nsq/frames/message'
9
+
10
+ require_relative 'nsq/consumer'
11
+ require_relative 'nsq/producer'
@@ -0,0 +1,391 @@
1
+ require 'json'
2
+ require 'socket'
3
+ require 'timeout'
4
+
5
+ require_relative 'frames/error'
6
+ require_relative 'frames/message'
7
+ require_relative 'frames/response'
8
+ require_relative 'logger'
9
+
10
+ module Nsq
11
+ class Connection
12
+ include Nsq::AttributeLogger
13
+ @@log_attributes = [:host, :port]
14
+
15
+ attr_reader :host
16
+ attr_reader :port
17
+ attr_accessor :max_in_flight
18
+ attr_reader :presumed_in_flight
19
+
20
+ USER_AGENT = "nsq-ruby/#{Nsq::Version::STRING}"
21
+ RESPONSE_HEARTBEAT = '_heartbeat_'
22
+ RESPONSE_OK = 'OK'
23
+
24
+
25
+ def initialize(opts = {})
26
+ @host = opts[:host] || (raise ArgumentError, 'host is required')
27
+ @port = opts[:port] || (raise ArgumentError, 'host is required')
28
+ @queue = opts[:queue]
29
+ @topic = opts[:topic]
30
+ @channel = opts[:channel]
31
+ @msg_timeout = opts[:msg_timeout] || 60_000 # 60s
32
+ @max_in_flight = opts[:max_in_flight] || 1
33
+
34
+ if @msg_timeout < 1000
35
+ raise ArgumentError, 'msg_timeout cannot be less than 1000. it\'s in milliseconds.'
36
+ end
37
+
38
+ # for outgoing communication
39
+ @write_queue = Queue.new
40
+
41
+ # For indicating that the connection has died.
42
+ # We use a Queue so we don't have to poll. Used to communicate across
43
+ # threads (from write_loop and read_loop to connect_and_monitor).
44
+ @death_queue = Queue.new
45
+
46
+ @connected = false
47
+ @presumed_in_flight = 0
48
+
49
+ open_connection
50
+ start_monitoring_connection
51
+ end
52
+
53
+
54
+ def connected?
55
+ @connected
56
+ end
57
+
58
+
59
+ # close the connection and don't try to re-open it
60
+ def close
61
+ stop_monitoring_connection
62
+ close_connection
63
+ end
64
+
65
+
66
+ def sub(topic, channel)
67
+ write "SUB #{topic} #{channel}\n"
68
+ end
69
+
70
+
71
+ def rdy(count)
72
+ write "RDY #{count}\n"
73
+ end
74
+
75
+
76
+ def fin(message_id)
77
+ write "FIN #{message_id}\n"
78
+ decrement_in_flight
79
+ end
80
+
81
+
82
+ def req(message_id, timeout)
83
+ write "REQ #{message_id} #{timeout}\n"
84
+ decrement_in_flight
85
+ end
86
+
87
+
88
+ def touch(message_id)
89
+ write "TOUCH #{message_id}\n"
90
+ end
91
+
92
+
93
+ def pub(topic, message)
94
+ write ["PUB #{topic}\n", message.bytesize, message].pack('a*l>a*')
95
+ end
96
+
97
+
98
+ def mpub(topic, messages)
99
+ body = messages.map do |message|
100
+ [message.bytesize, message].pack('l>a*')
101
+ end.join
102
+
103
+ write ["MPUB #{topic}\n", body.bytesize, messages.size, body].pack('a*l>l>a*')
104
+ end
105
+
106
+
107
+ # Tell the server we are ready for more messages!
108
+ def re_up_ready
109
+ rdy(@max_in_flight)
110
+ # assume these messages are coming our way. yes, this might not be the
111
+ # case, but it's much easier to manage our RDY state with the server if
112
+ # we treat things this way.
113
+ @presumed_in_flight = @max_in_flight
114
+ end
115
+
116
+
117
+ private
118
+
119
+ def cls
120
+ write "CLS\n"
121
+ end
122
+
123
+
124
+ def nop
125
+ write "NOP\n"
126
+ end
127
+
128
+
129
+ def write(raw)
130
+ @write_queue.push(raw)
131
+ end
132
+
133
+
134
+ def write_to_socket(raw)
135
+ debug ">>> #{raw.inspect}"
136
+ @socket.write(raw)
137
+ end
138
+
139
+
140
+ # Block until we get an OK from nsqd
141
+ def wait_for_ok
142
+ frame = receive_frame
143
+ unless frame.is_a?(Response) && frame.data == RESPONSE_OK
144
+ raise "Received non-OK response while IDENTIFYing: #{frame.data}"
145
+ end
146
+ end
147
+
148
+
149
+ def identify
150
+ hostname = Socket.gethostname
151
+ metadata = {
152
+ client_id: Socket.gethostbyname(hostname).flatten.compact.first,
153
+ hostname: hostname,
154
+ feature_negotiation: false,
155
+ heartbeat_interval: 30_000, # 30 seconds
156
+ output_buffer: 16_000, # 16kb
157
+ output_buffer_timeout: 250, # 250ms
158
+ tls_v1: false,
159
+ snappy: false,
160
+ deflate: false,
161
+ sample_rate: 0, # disable sampling
162
+ user_agent: USER_AGENT,
163
+ msg_timeout: @msg_timeout
164
+ }.to_json
165
+ write_to_socket ["IDENTIFY\n", metadata.length, metadata].pack('a*l>a*')
166
+ end
167
+
168
+
169
+ def handle_response(frame)
170
+ if frame.data == RESPONSE_HEARTBEAT
171
+ debug 'Received heartbeat'
172
+ nop
173
+ elsif frame.data == RESPONSE_OK
174
+ debug 'Received OK'
175
+ else
176
+ die "Received response we don't know how to handle: #{frame.data}"
177
+ end
178
+ end
179
+
180
+
181
+ def receive_frame
182
+ if buffer = @socket.read(8)
183
+ size, type = buffer.unpack('l>l>')
184
+ size -= 4 # we want the size of the data part and type already took up 4 bytes
185
+ data = @socket.read(size)
186
+ frame_class = frame_class_for_type(type)
187
+ return frame_class.new(data, self)
188
+ end
189
+ end
190
+
191
+
192
+ FRAME_CLASSES = [Response, Error, Message]
193
+ def frame_class_for_type(type)
194
+ raise "Bad frame type specified: #{type}" if type > FRAME_CLASSES.length - 1
195
+ [Response, Error, Message][type]
196
+ end
197
+
198
+
199
+ def decrement_in_flight
200
+ @presumed_in_flight -= 1
201
+
202
+ # now that we're less than @max_in_flight we might need to re-up our RDY
203
+ # state
204
+ threshold = (@max_in_flight * 0.2).ceil
205
+ re_up_ready if @presumed_in_flight <= threshold
206
+ end
207
+
208
+
209
+ def start_read_loop
210
+ @read_loop_thread ||= Thread.new{read_loop}
211
+ end
212
+
213
+
214
+ def stop_read_loop
215
+ @read_loop_thread.kill if @read_loop_thread
216
+ @read_loop_thread = nil
217
+ end
218
+
219
+
220
+ def read_loop
221
+ loop do
222
+ frame = receive_frame
223
+ if frame.is_a?(Response)
224
+ handle_response(frame)
225
+ elsif frame.is_a?(Error)
226
+ error "Error received: #{frame.data}"
227
+ elsif frame.is_a?(Message)
228
+ debug "<<< #{frame.body}"
229
+ @queue.push(frame) if @queue
230
+ else
231
+ raise 'No data from socket'
232
+ end
233
+ end
234
+ rescue Exception => ex
235
+ die(ex)
236
+ end
237
+
238
+
239
+ def start_write_loop
240
+ @write_loop_thread ||= Thread.new{write_loop}
241
+ end
242
+
243
+
244
+ def stop_write_loop
245
+ @stop_write_loop = true
246
+ @write_loop_thread.join(1) if @write_loop_thread
247
+ @write_loop_thread = nil
248
+ end
249
+
250
+
251
+ def write_loop
252
+ @stop_write_loop = false
253
+ data = nil
254
+ loop do
255
+ data = @write_queue.pop
256
+ write_to_socket(data)
257
+ break if @stop_write_loop && @write_queue.size == 0
258
+ end
259
+ rescue Exception => ex
260
+ # requeue PUB and MPUB commands
261
+ if data =~ /^M?PUB/
262
+ debug "Requeueing to write_queue: #{data.inspect}"
263
+ @write_queue.push(data)
264
+ end
265
+ die(ex)
266
+ end
267
+
268
+
269
+ # Waits for death of connection
270
+ def start_monitoring_connection
271
+ @connection_monitor_thread ||= Thread.new{monitor_connection}
272
+ @connection_monitor_thread.abort_on_exception = true
273
+ end
274
+
275
+
276
+ def stop_monitoring_connection
277
+ @connection_monitor_thread.kill if @connection_monitor_thread
278
+ @connection_monitor = nil
279
+ end
280
+
281
+
282
+ def monitor_connection
283
+ loop do
284
+ # wait for death, hopefully it never comes
285
+ cause_of_death = @death_queue.pop
286
+ warn "Died from: #{cause_of_death}"
287
+
288
+ debug 'Reconnecting...'
289
+ reconnect
290
+ debug 'Reconnected!'
291
+
292
+ # clear all death messages, since we're now reconnected.
293
+ # we don't want to complete this loop and immediately reconnect again.
294
+ @death_queue.clear
295
+ end
296
+ end
297
+
298
+
299
+ # close the connection if it's not already closed and try to reconnect
300
+ # over and over until we succeed!
301
+ def reconnect
302
+ close_connection
303
+ with_retries do
304
+ open_connection
305
+ end
306
+ end
307
+
308
+
309
+ def open_connection
310
+ @socket = TCPSocket.new(@host, @port)
311
+ # write the version and IDENTIFY directly to the socket to make sure
312
+ # it gets to nsqd ahead of anything in the `@write_queue`
313
+ write_to_socket ' V2'
314
+ identify
315
+ wait_for_ok
316
+
317
+ start_read_loop
318
+ start_write_loop
319
+ @connected = true
320
+
321
+ # we need to re-subscribe if there's a topic specified
322
+ if @topic
323
+ debug "Subscribing to #{@topic}"
324
+ sub(@topic, @channel)
325
+ re_up_ready
326
+ end
327
+ end
328
+
329
+
330
+ # closes the connection and stops listening for messages
331
+ def close_connection
332
+ cls if connected?
333
+ stop_read_loop
334
+ stop_write_loop
335
+ @socket = nil
336
+ @connected = false
337
+ end
338
+
339
+
340
+ # this is called when there's a connection error in the read or write loop
341
+ # it triggers `connect_and_monitor` to try to reconnect
342
+ def die(reason)
343
+ @connected = false
344
+ @death_queue.push(reason)
345
+ end
346
+
347
+
348
+ # Retry the supplied block with exponential backoff.
349
+ #
350
+ # Borrowed liberally from:
351
+ # https://github.com/ooyala/retries/blob/master/lib/retries.rb
352
+ def with_retries(&block)
353
+ base_sleep_seconds = 0.5
354
+ max_sleep_seconds = 300 # 5 minutes
355
+
356
+ # Let's do this thing
357
+ attempts = 0
358
+ start_time = Time.now
359
+
360
+ begin
361
+ attempts += 1
362
+ return block.call(attempts)
363
+
364
+ rescue Errno::ECONNREFUSED, Errno::ECONNRESET, Errno::EHOSTUNREACH,
365
+ Errno::ENETDOWN, Errno::ENETUNREACH, Errno::ETIMEDOUT, Timeout::Error => ex
366
+
367
+ raise ex if attempts >= 100
368
+
369
+ # The sleep time is an exponentially-increasing function of base_sleep_seconds.
370
+ # But, it never exceeds max_sleep_seconds.
371
+ sleep_seconds = [base_sleep_seconds * (2 ** (attempts - 1)), max_sleep_seconds].min
372
+ # Randomize to a random value in the range sleep_seconds/2 .. sleep_seconds
373
+ sleep_seconds = sleep_seconds * (0.5 * (1 + rand()))
374
+ # But never sleep less than base_sleep_seconds
375
+ sleep_seconds = [base_sleep_seconds, sleep_seconds].max
376
+
377
+ warn "Failed to connect: #{ex}. Retrying in #{sleep_seconds.round(1)} seconds."
378
+
379
+ snooze(sleep_seconds)
380
+
381
+ retry
382
+ end
383
+ end
384
+
385
+
386
+ # Se we can stub for testing and reconnect in a tight loop
387
+ def snooze(t)
388
+ sleep(t)
389
+ end
390
+ end
391
+ end
@@ -0,0 +1,146 @@
1
+ require_relative 'connection'
2
+ require_relative 'discovery'
3
+ require_relative 'logger'
4
+
5
+ module Nsq
6
+ class Consumer
7
+ include Nsq::AttributeLogger
8
+ @@log_attributes = [:topic]
9
+
10
+ attr_reader :topic
11
+ attr_reader :max_in_flight
12
+ attr_reader :discovery_interval
13
+ attr_reader :connections
14
+
15
+ def initialize(opts = {})
16
+ if opts[:nsqlookupd]
17
+ @nsqlookupds = [opts[:nsqlookupd]].flatten
18
+ else
19
+ @nsqlookupds = []
20
+ end
21
+
22
+ @topic = opts[:topic] || raise(ArgumentError, 'topic is required')
23
+ @channel = opts[:channel] || raise(ArgumentError, 'channel is required')
24
+ @max_in_flight = opts[:max_in_flight] || 1
25
+ @discovery_interval = opts[:discovery_interval] || 60
26
+ @msg_timeout = opts[:msg_timeout]
27
+
28
+ # This is where we queue up the messages we receive from each connection
29
+ @messages = Queue.new
30
+
31
+ # This is where we keep a record of our active nsqd connections
32
+ # The key is a string with the host and port of the instance (e.g.
33
+ # '127.0.0.1:4150') and the key is the Connection instance.
34
+ @connections = {}
35
+
36
+ if !@nsqlookupds.empty?
37
+ @discovery = Discovery.new(@nsqlookupds)
38
+ discover_repeatedly
39
+ else
40
+ # normally, we find nsqd instances to connect to via nsqlookupd(s)
41
+ # in this case let's connect to an nsqd instance directly
42
+ add_connection(opts[:nsqd] || '127.0.0.1:4150', @max_in_flight)
43
+ end
44
+
45
+ at_exit{terminate}
46
+ end
47
+
48
+
49
+ def terminate
50
+ @discovery_thread.kill if @discovery_thread
51
+ drop_all_connections
52
+ end
53
+
54
+
55
+ # pop the next message off the queue
56
+ def pop
57
+ @messages.pop
58
+ end
59
+
60
+
61
+ # returns the number of messages we have locally in the queue
62
+ def size
63
+ @messages.size
64
+ end
65
+
66
+
67
+ private
68
+ def discover_repeatedly
69
+ @discovery_thread = Thread.new do
70
+ loop do
71
+ discover
72
+ sleep @discovery_interval
73
+ end
74
+ end
75
+ @discovery_thread.abort_on_exception = true
76
+ end
77
+
78
+
79
+ def discover
80
+ nsqds = @discovery.nsqds_for_topic(@topic)
81
+
82
+ # drop nsqd connections that are no longer in lookupd
83
+ missing_nsqds = @connections.keys - nsqds
84
+ missing_nsqds.each do |nsqd|
85
+ drop_connection(nsqd)
86
+ end
87
+
88
+ # add new ones
89
+ new_nsqds = nsqds - @connections.keys
90
+ new_nsqds.each do |nsqd|
91
+ # Be conservative and start new connections with RDY 1
92
+ # This helps ensure we don't exceed @max_in_flight across all our
93
+ # connections momentarily.
94
+ add_connection(nsqd, 1)
95
+ end
96
+
97
+ # balance RDY state amongst the connections
98
+ redistribute_ready
99
+ end
100
+
101
+
102
+ def add_connection(nsqd, max_in_flight)
103
+ info "+ Adding connection #{nsqd}"
104
+ host, port = nsqd.split(':')
105
+ connection = Connection.new(
106
+ host: host,
107
+ port: port,
108
+ topic: @topic,
109
+ channel: @channel,
110
+ queue: @messages,
111
+ msg_timeout: @msg_timeout,
112
+ max_in_flight: max_in_flight
113
+ )
114
+ @connections[nsqd] = connection
115
+ end
116
+
117
+
118
+ def drop_connection(nsqd)
119
+ info "- Dropping connection #{nsqd}"
120
+ connection = @connections.delete(nsqd)
121
+ connection.close
122
+ redistribute_ready
123
+ end
124
+
125
+
126
+ def redistribute_ready
127
+ @connections.values.each do |connection|
128
+ connection.max_in_flight = max_in_flight_per_connection
129
+ connection.re_up_ready
130
+ end
131
+ end
132
+
133
+
134
+ def drop_all_connections
135
+ @connections.keys.each do |nsqd|
136
+ drop_connection(nsqd)
137
+ end
138
+ end
139
+
140
+
141
+ # Be conservative, but don't set a connection's max_in_flight below 1
142
+ def max_in_flight_per_connection(number_of_connections = @connections.length)
143
+ [@max_in_flight / number_of_connections, 1].max
144
+ end
145
+ end
146
+ end
@@ -0,0 +1,56 @@
1
+ require 'json'
2
+ require 'net/http'
3
+ require 'uri'
4
+
5
+ require_relative 'logger'
6
+
7
+ # Connects to nsqlookup's to find the nsqd instances for a given topic
8
+ module Nsq
9
+ class Discovery
10
+ include Nsq::AttributeLogger
11
+
12
+ # lookupd addresses must be formatted like so: '<host>:<http-port>'
13
+ def initialize(lookupds)
14
+ @lookupds = lookupds
15
+ end
16
+
17
+ # Given a topic, returns an array of nsqds instances that have messages for
18
+ # that topic.
19
+ #
20
+ # nsqd instances returned are strings in this format: '<host>:<tcp-port>'
21
+ #
22
+ # discovery.nsqds_for_topic('some-topic')
23
+ # #=> ['127.0.0.1:4150', '127.0.0.1:4152']
24
+ #
25
+ def nsqds_for_topic(topic)
26
+ @lookupds.map do |lookupd|
27
+ get_nsqds_for_topic(lookupd, topic)
28
+ end.flatten.uniq
29
+ end
30
+
31
+
32
+ private
33
+
34
+ def get_nsqds_for_topic(lookupd, topic)
35
+ uri = URI.parse("http://#{lookupd}")
36
+ uri.path = '/lookup'
37
+ uri.query = "topic=#{topic}&ts=#{Time.now.to_i}"
38
+ begin
39
+ body = Net::HTTP.get(uri)
40
+ data = JSON.parse(body)
41
+
42
+ if data['data'] && data['data']['producers']
43
+ data['data']['producers'].map do |producer|
44
+ "#{producer['broadcast_address']}:#{producer['tcp_port']}"
45
+ end
46
+ else
47
+ []
48
+ end
49
+ rescue Exception => e
50
+ error "Error during discovery for #{lookupd}: #{e}"
51
+ []
52
+ end
53
+ end
54
+
55
+ end
56
+ end
@@ -0,0 +1,6 @@
1
+ require_relative 'frame'
2
+
3
+ module Nsq
4
+ class Error < Frame
5
+ end
6
+ end
@@ -0,0 +1,16 @@
1
+ require_relative '../logger'
2
+
3
+ module Nsq
4
+ class Frame
5
+ include Nsq::AttributeLogger
6
+ @@log_attributes = [:connection]
7
+
8
+ attr_reader :data
9
+ attr_reader :connection
10
+
11
+ def initialize(data, connection)
12
+ @data = data
13
+ @connection = connection
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,30 @@
1
+ require_relative 'frame'
2
+
3
+ module Nsq
4
+ class Message < Frame
5
+
6
+ attr_reader :timestamp
7
+ attr_reader :attempts
8
+ attr_reader :id
9
+ attr_reader :body
10
+
11
+ def initialize(data, connection)
12
+ super
13
+ @timestamp, @attempts, @id, @body = @data.unpack('Q>s>a16a*')
14
+ @body.force_encoding('UTF-8')
15
+ end
16
+
17
+ def finish
18
+ connection.fin(id)
19
+ end
20
+
21
+ def requeue(timeout = 0)
22
+ connection.req(id, timeout)
23
+ end
24
+
25
+ def touch
26
+ connection.touch(id)
27
+ end
28
+
29
+ end
30
+ end
@@ -0,0 +1,6 @@
1
+ require_relative 'frame'
2
+
3
+ module Nsq
4
+ class Response < Frame
5
+ end
6
+ end
data/lib/nsq/logger.rb ADDED
@@ -0,0 +1,38 @@
1
+ require 'logger'
2
+ module Nsq
3
+ @@logger = Logger.new(nil)
4
+
5
+
6
+ def self.logger
7
+ @@logger
8
+ end
9
+
10
+
11
+ def self.logger=(new_logger)
12
+ @@logger = new_logger
13
+ end
14
+
15
+
16
+ module AttributeLogger
17
+ def self.included(klass)
18
+ klass.send :class_variable_set, :@@log_attributes, []
19
+ end
20
+
21
+ %w(fatal error warn info debug).map{|m| m.to_sym}.each do |level|
22
+ define_method level do |msg|
23
+ Nsq.logger.send(level, "#{prefix} #{msg}")
24
+ end
25
+ end
26
+
27
+
28
+ private
29
+ def prefix
30
+ attrs = self.class.send(:class_variable_get, :@@log_attributes)
31
+ if attrs.count > 0
32
+ "[#{attrs.map{|a| "#{a.to_s}: #{self.send(a)}"}.join(' ')}] "
33
+ else
34
+ ''
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,48 @@
1
+ require_relative 'connection'
2
+ require_relative 'logger'
3
+
4
+ module Nsq
5
+ class Producer
6
+ include Nsq::AttributeLogger
7
+ @@log_attributes = [:host, :port, :topic]
8
+
9
+ attr_reader :host
10
+ attr_reader :port
11
+ attr_reader :topic
12
+
13
+
14
+ def initialize(opts = {})
15
+ @nsqd = opts[:nsqd] || '127.0.0.1:4150'
16
+ @host, @port = @nsqd.split(':')
17
+
18
+ @topic = opts[:topic] || raise(ArgumentError, 'topic is required')
19
+
20
+ @connection = Connection.new(host: @host, port: @port)
21
+
22
+ at_exit{terminate}
23
+ end
24
+
25
+
26
+ def write(*raw_messages)
27
+ # stringify them
28
+ messages = raw_messages.map(&:to_s)
29
+
30
+ if messages.length > 1
31
+ @connection.mpub(@topic, messages)
32
+ else
33
+ @connection.pub(@topic, messages.first)
34
+ end
35
+ end
36
+
37
+
38
+ def connected?
39
+ @connection.connected?
40
+ end
41
+
42
+
43
+ def terminate
44
+ @connection.close
45
+ end
46
+
47
+ end
48
+ end
data/lib/version.rb ADDED
@@ -0,0 +1,9 @@
1
+ module Nsq
2
+ module Version
3
+ MAJOR = 0
4
+ MINOR = 1
5
+ PATCH = 0
6
+ BUILD = nil
7
+ STRING = [MAJOR, MINOR, PATCH, BUILD].compact.join('.')
8
+ end
9
+ end
metadata ADDED
@@ -0,0 +1,114 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: nsq-ruby
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Wistia
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2014-07-28 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: bundler
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '1.0'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '1.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: jeweler
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: 2.0.1
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: 2.0.1
41
+ - !ruby/object:Gem::Dependency
42
+ name: nsq-cluster
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: 0.2.7
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: 0.2.7
55
+ - !ruby/object:Gem::Dependency
56
+ name: rspec
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: 3.0.0
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: 3.0.0
69
+ description: ''
70
+ email: dev@wistia.com
71
+ executables: []
72
+ extensions: []
73
+ extra_rdoc_files:
74
+ - LICENSE.txt
75
+ - README.md
76
+ files:
77
+ - LICENSE.txt
78
+ - README.md
79
+ - lib/nsq.rb
80
+ - lib/nsq/connection.rb
81
+ - lib/nsq/consumer.rb
82
+ - lib/nsq/discovery.rb
83
+ - lib/nsq/frames/error.rb
84
+ - lib/nsq/frames/frame.rb
85
+ - lib/nsq/frames/message.rb
86
+ - lib/nsq/frames/response.rb
87
+ - lib/nsq/logger.rb
88
+ - lib/nsq/producer.rb
89
+ - lib/version.rb
90
+ homepage: http://github.com/wistia/nsq-ruby
91
+ licenses:
92
+ - MIT
93
+ metadata: {}
94
+ post_install_message:
95
+ rdoc_options: []
96
+ require_paths:
97
+ - lib
98
+ required_ruby_version: !ruby/object:Gem::Requirement
99
+ requirements:
100
+ - - ">="
101
+ - !ruby/object:Gem::Version
102
+ version: '0'
103
+ required_rubygems_version: !ruby/object:Gem::Requirement
104
+ requirements:
105
+ - - ">="
106
+ - !ruby/object:Gem::Version
107
+ version: '0'
108
+ requirements: []
109
+ rubyforge_project:
110
+ rubygems_version: 2.2.2
111
+ signing_key:
112
+ specification_version: 4
113
+ summary: Ruby client library for NSQ
114
+ test_files: []