nsq-krakow 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.
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