nsq-krakow 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (42) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +84 -0
  3. data/CONTRIBUTING.md +25 -0
  4. data/LICENSE +13 -0
  5. data/README.md +249 -0
  6. data/krakow.gemspec +22 -0
  7. data/lib/krakow.rb +25 -0
  8. data/lib/krakow/command.rb +89 -0
  9. data/lib/krakow/command/auth.rb +36 -0
  10. data/lib/krakow/command/cls.rb +24 -0
  11. data/lib/krakow/command/fin.rb +31 -0
  12. data/lib/krakow/command/identify.rb +55 -0
  13. data/lib/krakow/command/mpub.rb +39 -0
  14. data/lib/krakow/command/nop.rb +14 -0
  15. data/lib/krakow/command/pub.rb +37 -0
  16. data/lib/krakow/command/rdy.rb +31 -0
  17. data/lib/krakow/command/req.rb +32 -0
  18. data/lib/krakow/command/sub.rb +36 -0
  19. data/lib/krakow/command/touch.rb +31 -0
  20. data/lib/krakow/connection.rb +417 -0
  21. data/lib/krakow/connection_features.rb +10 -0
  22. data/lib/krakow/connection_features/deflate.rb +82 -0
  23. data/lib/krakow/connection_features/snappy_frames.rb +129 -0
  24. data/lib/krakow/connection_features/ssl.rb +75 -0
  25. data/lib/krakow/consumer.rb +355 -0
  26. data/lib/krakow/consumer/queue.rb +151 -0
  27. data/lib/krakow/discovery.rb +57 -0
  28. data/lib/krakow/distribution.rb +229 -0
  29. data/lib/krakow/distribution/default.rb +159 -0
  30. data/lib/krakow/exceptions.rb +30 -0
  31. data/lib/krakow/frame_type.rb +66 -0
  32. data/lib/krakow/frame_type/error.rb +26 -0
  33. data/lib/krakow/frame_type/message.rb +74 -0
  34. data/lib/krakow/frame_type/response.rb +26 -0
  35. data/lib/krakow/ksocket.rb +102 -0
  36. data/lib/krakow/producer.rb +162 -0
  37. data/lib/krakow/producer/http.rb +224 -0
  38. data/lib/krakow/utils.rb +9 -0
  39. data/lib/krakow/utils/lazy.rb +125 -0
  40. data/lib/krakow/utils/logging.rb +43 -0
  41. data/lib/krakow/version.rb +4 -0
  42. metadata +184 -0
@@ -0,0 +1,10 @@
1
+ require 'krakow'
2
+
3
+ module Krakow
4
+ # Features that wrap the connection
5
+ module ConnectionFeatures
6
+ autoload :SnappyFrames, 'krakow/connection_features/snappy_frames'
7
+ autoload :Deflate, 'krakow/connection_features/deflate'
8
+ autoload :Ssl, 'krakow/connection_features/ssl'
9
+ end
10
+ end
@@ -0,0 +1,82 @@
1
+ require 'zlib'
2
+ require 'krakow'
3
+
4
+ module Krakow
5
+ module ConnectionFeatures
6
+ # Deflate functionality
7
+ module Deflate
8
+ # Deflatable IO
9
+ class Io
10
+
11
+ attr_reader :io, :buffer, :headers, :inflator, :deflator
12
+
13
+ # Create new deflatable IO
14
+ #
15
+ # @param io [IO] IO to wrap
16
+ # @return [Io]
17
+ def initialize(io, args={})
18
+ @io = io
19
+ @buffer = ''
20
+ @inflator = Zlib::Inflate.new(-Zlib::MAX_WBITS)
21
+ @deflator = Zlib::Deflate.new(nil, -Zlib::MAX_WBITS)
22
+ end
23
+
24
+ # Proxy to underlying socket
25
+ #
26
+ # @param args [Object]
27
+ # @return [Object]
28
+ def method_missing(*args)
29
+ io.__send__(*args)
30
+ end
31
+
32
+ # Receive bytes from the IO
33
+ #
34
+ # @param n [Integer] nuber of bytes
35
+ # @return [String]
36
+ def recv(n)
37
+ until(buffer.length >= n)
38
+ read_stream
39
+ sleep(0.1) unless buffer.length >= n
40
+ end
41
+ buffer.slice!(0, n)
42
+ end
43
+ alias_method :read, :recv
44
+
45
+ # Read contents from stream
46
+ #
47
+ # @return [String]
48
+ def read_stream
49
+ str = io.read
50
+ unless(str.empty?)
51
+ buffer << inflator.inflate(str)
52
+ end
53
+ end
54
+
55
+ # Write string to IO
56
+ #
57
+ # @param string [String]
58
+ # @return [Integer] number of bytes written
59
+ def write(string)
60
+ unless(string.empty?)
61
+ output = deflator.deflate(string)
62
+ output << deflator.flush
63
+ io.write(output)
64
+ else
65
+ 0
66
+ end
67
+ end
68
+
69
+ # Close the IO
70
+ #
71
+ # @return [TrueClass]
72
+ def close(*args)
73
+ super
74
+ deflator.deflate(nil, Zlib::FINISH)
75
+ deflator.close
76
+ true
77
+ end
78
+
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,129 @@
1
+ begin
2
+ require 'snappy'
3
+ rescue LoadError
4
+ $stderr.puts 'ERROR: Failed to locate `snappy` gem. Install `snappy` gem into system or bundle.'
5
+ raise
6
+ end
7
+ require 'digest/crc'
8
+ require 'krakow'
9
+
10
+ module Krakow
11
+ module ConnectionFeatures
12
+ # Snappy functionality
13
+ # @todo Add support for max size + chunks
14
+ # @todo Include support for remaining types
15
+ module SnappyFrames
16
+ # Snappy-able IO
17
+ class Io
18
+
19
+ # Header identifier
20
+ IDENTIFIER = "\x73\x4e\x61\x50\x70\x59".force_encoding('ASCII-8BIT')
21
+ ident_size = [IDENTIFIER.size].pack('L<')
22
+ ident_size.slice!(-1,1)
23
+ # Size of identifier
24
+ IDENTIFIER_SIZE = ident_size
25
+
26
+ # Mapping of types
27
+ CHUNK_TYPE = {
28
+ "\xff".force_encoding('ASCII-8BIT') => :identifier,
29
+ "\x00".force_encoding('ASCII-8BIT') => :compressed,
30
+ "\x01".force_encoding('ASCII-8BIT') => :uncompressed
31
+ }
32
+
33
+ attr_reader :io, :buffer
34
+
35
+ # Create new snappy-able IO
36
+ #
37
+ # @param io [IO] IO to wrap
38
+ # @return [Io]
39
+ def initialize(io, args={})
40
+ @io = io
41
+ @snappy_write_ident = false
42
+ @buffer = ''
43
+ end
44
+
45
+ # Proxy to underlying socket
46
+ #
47
+ # @param args [Object]
48
+ # @return [Object]
49
+ def method_missing(*args)
50
+ io.__send__(*args)
51
+ end
52
+
53
+ # Mask the checksum
54
+ #
55
+ # @param checksum [String]
56
+ # @return [String]
57
+ def checksum_mask(checksum)
58
+ (((checksum >> 15) | (checksum << 17)) + 0xa282ead8) & 0xffffffff
59
+ end
60
+
61
+ # Receive bytes from the IO
62
+ #
63
+ # @param n [Integer] nuber of bytes
64
+ # @return [String]
65
+ def recv(n)
66
+ read_stream unless buffer.size >= n
67
+ result = buffer.slice!(0,n)
68
+ result.empty? ? nil : result
69
+ end
70
+ alias_method :read, :recv
71
+
72
+ # Read contents from stream
73
+ #
74
+ # @return [String]
75
+ def read_stream
76
+ header = io.recv(4)
77
+ ident = CHUNK_TYPE[header.slice!(0)]
78
+ size = (header << CHUNK_TYPE.key(:compressed)).unpack('L<').first
79
+ content = io.recv(size)
80
+ case ident
81
+ when :identifier
82
+ unless(content == IDENTIFIER)
83
+ raise "Invalid stream identification encountered (content: #{content.inspect})"
84
+ end
85
+ read_stream
86
+ when :compressed
87
+ checksum = content.slice!(0, 4).unpack('L<').first
88
+ deflated = Snappy.inflate(content)
89
+ digest = Digest::CRC32c.new
90
+ digest << deflated
91
+ unless(checksum == checksum_mask(digest.checksum))
92
+ raise 'Checksum mismatch!'
93
+ end
94
+ buffer << deflated
95
+ when :uncompressed
96
+ buffer << content
97
+ end
98
+ end
99
+
100
+ # Write string to IO
101
+ #
102
+ # @param string [String]
103
+ # @return [Integer] number of bytes written
104
+ def write(string)
105
+ unless(@snappy_writer_ident)
106
+ send_snappy_identifier
107
+ end
108
+ digest = Digest::CRC32c.new
109
+ digest << string
110
+ content = Snappy.deflate(string)
111
+ size = content.length + 4
112
+ size = [size].pack('L<')
113
+ size.slice!(-1,1)
114
+ checksum = [checksum_mask(digest.checksum)].pack('L<')
115
+ output = [CHUNK_TYPE.key(:compressed), size, checksum, content].pack('a*a*a*a*')
116
+ io.write output
117
+ end
118
+
119
+ # Send the identifier for snappy content
120
+ #
121
+ # @return [Integer] bytes written
122
+ def send_snappy_identifier
123
+ io.write [CHUNK_TYPE.key(:identifier), IDENTIFIER_SIZE, IDENTIFIER].pack('a*a*a*')
124
+ end
125
+
126
+ end
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,75 @@
1
+ require 'openssl'
2
+ require 'krakow'
3
+
4
+ module Krakow
5
+ module ConnectionFeatures
6
+ # SSL functionality
7
+ module Ssl
8
+ # SSL-able IO
9
+ class Io
10
+
11
+ attr_reader :_socket
12
+
13
+ # Create new SSL-able IO
14
+ #
15
+ # @param io [IO] IO to wrap
16
+ # @param args [Hash]
17
+ # @option args [Hash] :ssl_context
18
+ # @return [Io]
19
+ def initialize(io, args={})
20
+ ssl_socket_arguments = [io]
21
+ if(args[:ssl_context])
22
+ validate_ssl_args!(args[:ssl_context])
23
+ context = OpenSSL::SSL::SSLContext.new
24
+ context.cert = OpenSSL::X509::Certificate.new(File.open(args[:ssl_context][:certificate]))
25
+ context.key = OpenSSL::PKey::RSA.new(File.open(args[:ssl_context][:key]))
26
+ ssl_socket_arguments << context
27
+ end
28
+ @_socket = Celluloid::IO::SSLSocket.new(*ssl_socket_arguments)
29
+ _socket.sync = true
30
+ _socket.connect
31
+ end
32
+
33
+ # Proxy to underlying socket
34
+ #
35
+ # @param args [Object]
36
+ # @return [Object]
37
+ def method_missing(*args)
38
+ _socket.send(*args)
39
+ end
40
+
41
+ # Receive bytes from the IO
42
+ #
43
+ # @param len [Integer] nuber of bytes
44
+ # @return [String]
45
+ def recv(len)
46
+ str = readpartial(len)
47
+ if(len > str.length)
48
+ str << sysread(len - str.length)
49
+ end
50
+ str
51
+ end
52
+
53
+ private
54
+
55
+ # Validate the SSL configuration provided
56
+ #
57
+ # @param args [Hash]
58
+ # @option args [String] :certificate path to certificate
59
+ # @option args [String] :key path to key
60
+ # @raise [ArgumentError, LoadError]
61
+ def validate_ssl_args!(args)
62
+ [:key, :certificate].each do |arg_key|
63
+ unless(args.has_key?(arg_key))
64
+ raise ArgumentError.new "The `:ssl_context` option requires `#{arg_key.inspect}` to be set"
65
+ end
66
+ unless(File.readable?(args[arg_key]))
67
+ raise LoadError.new "Unable to read the `#{arg_key.inspect}` file from the `:ssl_context` arguments"
68
+ end
69
+ end
70
+ end
71
+
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,355 @@
1
+ require 'krakow'
2
+
3
+ module Krakow
4
+ # Consume messages from a server
5
+ class Consumer
6
+
7
+ autoload :Queue, 'krakow/consumer/queue'
8
+
9
+ include Utils::Lazy
10
+ # @!parse include Krakow::Utils::Lazy::InstanceMethods
11
+ # @!parse extend Krakow::Utils::Lazy::ClassMethods
12
+
13
+ include Celluloid
14
+
15
+ trap_exit :connection_failure
16
+ finalizer :consumer_cleanup
17
+
18
+ attr_reader :connections, :discovery, :distribution, :queue
19
+
20
+ # @!group Attributes
21
+
22
+ # @!macro [attach] attribute
23
+ # @!method $1
24
+ # @return [$2] the $1 $0
25
+ # @!method $1?
26
+ # @return [TrueClass, FalseClass] truthiness of the $1 $0
27
+ attribute :topic, String, :required => true
28
+ attribute :channel, String, :required => true
29
+ attribute :host, String
30
+ attribute :port, [String, Integer]
31
+ attribute :nsqlookupd, [Array, String]
32
+ attribute :max_in_flight, Integer, :default => 1
33
+ attribute :backoff_interval, Numeric
34
+ attribute :discovery_interval, Numeric, :default => 30
35
+ attribute :discovery_jitter, Numeric, :default => 10.0
36
+ attribute :notifier, [Celluloid::Signals, Celluloid::Condition, Celluloid::Actor]
37
+ attribute :connection_options, Hash, :default => ->{ Hash.new }
38
+
39
+ # @!endgroup
40
+
41
+ def initialize(args={})
42
+ super
43
+ arguments[:connection_options] = {:features => {}, :config => {}}.merge(
44
+ arguments[:connection_options] || {}
45
+ )
46
+ @connections = {}
47
+ @queue = Queue.new(
48
+ current_actor,
49
+ :removal_callback => :remove_message
50
+ )
51
+ @distribution = Distribution::Default.new(
52
+ :max_in_flight => max_in_flight,
53
+ :backoff_interval => backoff_interval,
54
+ :consumer => current_actor
55
+ )
56
+ if(nsqlookupd)
57
+ debug "Connections will be established via lookup #{nsqlookupd.inspect}"
58
+ @discovery = Discovery.new(:nsqlookupd => nsqlookupd)
59
+ discover
60
+ elsif(host && port)
61
+ direct_connect
62
+ else
63
+ abort Error::ConfigurationError.new('No connection information provided!')
64
+ end
65
+ end
66
+
67
+ # @return [TrueClass, FalseClass] currently connected to at least
68
+ # one nsqd
69
+ def connected?
70
+ !!connections.values.any? do |con|
71
+ begin
72
+ con.connected?
73
+ rescue Celluloid::DeadActorError
74
+ false
75
+ end
76
+ end
77
+ end
78
+
79
+ # Connect to nsqd instance directly
80
+ #
81
+ # @return [Connection]
82
+ def direct_connect
83
+ debug "Connection will be established via direct connection #{host}:#{port}"
84
+ connection = build_connection(host, port, queue)
85
+ if(register(connection))
86
+ info "Registered new connection #{connection}"
87
+ distribution.redistribute!
88
+ else
89
+ abort Error::ConnectionFailure.new("Failed to establish subscription at provided end point (#{host}:#{port}")
90
+ end
91
+ connection
92
+ end
93
+
94
+ # Returns [Krakow::Connection] associated to key
95
+ #
96
+ # @param key [Object] identifier
97
+ # @return [Krakow::Connection] associated connection
98
+ def connection(key)
99
+ @connections[key]
100
+ end
101
+
102
+ # @return [String] stringify object
103
+ def to_s
104
+ "<#{self.class.name}:#{object_id} T:#{topic} C:#{channel}>"
105
+ end
106
+
107
+ # Instance destructor
108
+ #
109
+ # @return [nil]
110
+ def consumer_cleanup
111
+ debug 'Tearing down consumer'
112
+ if(distribution && distribution.alive?)
113
+ distribution.terminate
114
+ end
115
+ if(queue && queue.alive?)
116
+ queue.terminate
117
+ end
118
+ connections.values.each do |con|
119
+ con.terminate if con.alive?
120
+ end
121
+ info 'Consumer torn down'
122
+ nil
123
+ end
124
+
125
+ # Build a new [Krakow::Connection]
126
+ #
127
+ # @param host [String] remote host
128
+ # @param port [String, Integer] remote port
129
+ # @param queue [Queue] queue for messages
130
+ # @return [Krakow::Connection, nil] new connection or nil
131
+ def build_connection(host, port, queue)
132
+ begin
133
+ connection = Connection.new(
134
+ :host => host,
135
+ :port => port,
136
+ :queue => queue,
137
+ :topic => topic,
138
+ :channel => channel,
139
+ :notifier => notifier,
140
+ :features => connection_options[:features],
141
+ :features_args => connection_options[:config],
142
+ :callbacks => {
143
+ :handle => {
144
+ :actor => current_actor,
145
+ :method => :process_message
146
+ }
147
+ }
148
+ )
149
+ queue.register_connection(connection)
150
+ connection
151
+ rescue => e
152
+ error "Failed to build connection (host: #{host} port: #{port} queue: #{queue}) - #{e.class}: #{e}"
153
+ debug "#{e.class}: #{e}\n#{e.backtrace.join("\n")}"
154
+ nil
155
+ end
156
+ end
157
+
158
+ # Process a given message if required
159
+ #
160
+ # @param message [Krakow::FrameType]
161
+ # @param connection [Krakow::Connection]
162
+ # @return [Krakow::FrameType]
163
+ # @note If we receive a message that is already in flight, attempt
164
+ # to scrub message from wait queue. If message is found, retry
165
+ # distribution registration. If message is not found, assume it
166
+ # is currently being processed and do not allow new message to
167
+ # be queued
168
+ def process_message(message, connection)
169
+ discard = false
170
+ if(message.is_a?(FrameType::Message))
171
+ message.origin = current_actor
172
+ message.connection = connection
173
+ retried = false
174
+ begin
175
+ distribution.register_message(message, connection.identifier)
176
+ rescue KeyError => e
177
+ if(!retried && queue.scrub_duplicate_message(message))
178
+ retried = true
179
+ retry
180
+ else
181
+ error "Received message is currently in flight and not in wait queue. Discarding! (#{message})"
182
+ discard = true
183
+ end
184
+ end
185
+ end
186
+ discard ? nil : message
187
+ end
188
+
189
+ # Send RDY for connection based on distribution rules
190
+ #
191
+ # @param connection [Krakow::Connection]
192
+ # @return [nil]
193
+ def update_ready!(connection)
194
+ distribution.set_ready_for(connection)
195
+ nil
196
+ end
197
+
198
+ # Initialize the consumer by starting lookup and adding connections
199
+ #
200
+ # @return [nil]
201
+ def init!
202
+ debug 'Running consumer `init!` connection builds'
203
+ found = discovery.lookup(topic)
204
+ debug "Discovery results: #{found.inspect}"
205
+ connection = nil
206
+ found.each do |node|
207
+ debug "Processing discovery result: #{node.inspect}"
208
+ key = Connection.identifier(node[:broadcast_address], node[:tcp_port], topic, channel)
209
+ unless(connections[key])
210
+ connection = build_connection(node[:broadcast_address], node[:tcp_port], queue)
211
+ info "Registered new connection #{connection}" if register(connection)
212
+ else
213
+ debug "Discovery result already registered: #{node.inspect}"
214
+ end
215
+ end
216
+ distribution.redistribute! if connection
217
+ nil
218
+ end
219
+
220
+ # Start the discovery interval lookup
221
+ #
222
+ # @return [nil]
223
+ def discover
224
+ init!
225
+ after(discovery_interval + (discovery_jitter * rand)){ discover }
226
+ end
227
+
228
+ # Register connection with distribution
229
+ #
230
+ # @param connection [Krakow::Connection]
231
+ # @return [TrueClass, FalseClass] true if subscription was successful
232
+ def register(connection)
233
+ begin
234
+ connection.init!
235
+ connection.transmit(Command::Sub.new(:topic_name => topic, :channel_name => channel))
236
+ self.link connection
237
+ connections[connection.identifier] = connection
238
+ distribution.add_connection(connection)
239
+ true
240
+ rescue Error::BadResponse => e
241
+ debug "Failed to establish connection: #{e.result ? e.result.error : '<No Response!>'}"
242
+ connection.terminate
243
+ false
244
+ end
245
+ end
246
+
247
+ # Remove connection references when connection is terminated
248
+ #
249
+ # @param actor [Object] terminated actor
250
+ # @param reason [Exception] reason for termination
251
+ # @return [nil]
252
+ def connection_failure(actor, reason)
253
+ if(reason && key = connections.key(actor))
254
+ warn "Connection failure detected. Removing connection: #{key} - #{reason}"
255
+ connections.delete(key)
256
+ begin
257
+ distribution.remove_connection(key)
258
+ rescue Error::ConnectionUnavailable, Error::ConnectionFailure
259
+ warn 'Caught connection unavailability'
260
+ end
261
+ queue.deregister_connection(key)
262
+ distribution.redistribute!
263
+ direct_connect unless discovery
264
+ end
265
+ nil
266
+ end
267
+
268
+ # Remove message
269
+ #
270
+ # @param messages [Array<FrameType::Message>]
271
+ # @return [NilClass]
272
+ # @note used mainly for queue callback
273
+ def remove_message(messages)
274
+ [messages].flatten.compact.each do |msg|
275
+ distribution.unregister_message(msg.message_id)
276
+ update_ready!(msg.connection)
277
+ end
278
+ nil
279
+ end
280
+
281
+ # Confirm message has been processed
282
+ #
283
+ # @param message_id [String, Krakow::FrameType::Message]
284
+ # @return [TrueClass]
285
+ # @raise [KeyError] connection not found
286
+ def confirm(message_id)
287
+ message_id = message_id.message_id if message_id.respond_to?(:message_id)
288
+ begin
289
+ begin
290
+ connection = distribution.in_flight_lookup(message_id)
291
+ connection.transmit(Command::Fin.new(:message_id => message_id))
292
+ distribution.success(connection.identifier)
293
+ rescue => e
294
+ abort e
295
+ end
296
+ true
297
+ rescue KeyError => e
298
+ error "Message confirmation failed: #{e}"
299
+ abort e
300
+ rescue Error::LookupFailed => e
301
+ error "Lookup of message for confirmation failed! <Message ID: #{message_id} - Error: #{e}>"
302
+ abort e
303
+ rescue Error::ConnectionUnavailable => e
304
+ abort e
305
+ rescue Celluloid::DeadActorError
306
+ abort Error::ConnectionUnavailable.new
307
+ ensure
308
+ con = distribution.unregister_message(message_id)
309
+ update_ready!(con) if con
310
+ end
311
+ end
312
+ alias_method :finish, :confirm
313
+
314
+ # Requeue message (generally due to processing failure)
315
+ #
316
+ # @param message_id [String, Krakow::FrameType::Message]
317
+ # @param timeout [Numeric]
318
+ # @return [TrueClass]
319
+ def requeue(message_id, timeout=0)
320
+ message_id = message_id.message_id if message_id.respond_to?(:message_id)
321
+ distribution.in_flight_lookup(message_id) do |connection|
322
+ distribution.unregister_message(message_id)
323
+ connection.transmit(
324
+ Command::Req.new(
325
+ :message_id => message_id,
326
+ :timeout => timeout
327
+ )
328
+ )
329
+ distribution.failure(connection.identifier)
330
+ update_ready!(connection)
331
+ end
332
+ true
333
+ end
334
+
335
+ # Touch message (to extend timeout)
336
+ #
337
+ # @param message_id [String, Krakow::FrameType::Message]
338
+ # @return [TrueClass]
339
+ def touch(message_id)
340
+ message_id = message_id.message_id if message_id.respond_to?(:message_id)
341
+ begin
342
+ distribution.in_flight_lookup(message_id) do |connection|
343
+ connection.transmit(
344
+ Command::Touch.new(:message_id => message_id)
345
+ )
346
+ end
347
+ true
348
+ rescue Error::LookupFailed => e
349
+ error "Lookup of message for touch failed! <Message ID: #{message_id} - Error: #{e}>"
350
+ abort e
351
+ end
352
+ end
353
+
354
+ end
355
+ end