nsq-ruby 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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: []