logstash-input-redis_cluster 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,395 @@
1
+ # encoding: utf-8
2
+ require "logstash/namespace"
3
+ require "logstash/inputs/base"
4
+ require "logstash/inputs/threadable"
5
+ require 'redis'
6
+ require 'redis-cluster-client'
7
+ require "stud/interval"
8
+
9
+ # This input will read events from a Redis instance; it supports both Redis channels and lists.
10
+ # The list command (BLPOP) used by Logstash is supported in Redis v1.3.1+, and
11
+ # the channel commands used by Logstash are found in Redis v1.3.8+.
12
+ # While you may be able to make these Redis versions work, the best performance
13
+ # and stability will be found in more recent stable versions. Versions 2.6.0+
14
+ # are recommended.
15
+ #
16
+ # For more information about Redis, see <http://redis.io/>
17
+ #
18
+ # `batch_count` note: If you use the `batch_count` setting, you *must* use a Redis version 2.6.0 or
19
+ # newer. Anything older does not support the operations used by batching.
20
+ #
21
+ module LogStash module Inputs class RedisCluster < LogStash::Inputs::Threadable
22
+ BATCH_EMPTY_SLEEP = 0.25
23
+
24
+ config_name "redis_cluster"
25
+
26
+ default :codec, "json"
27
+
28
+ # The list of your Redis cluster nodes.
29
+ config :nodes, :list => true, :validate => :string, :default => []
30
+ config :fixed_hostname, :validate => :string
31
+
32
+ # The port to connect on.
33
+ # config :port, :validate => :number, :default => 6379
34
+
35
+ # SSL
36
+ config :ssl, :validate => :boolean, :default => false
37
+
38
+ # The unix socket path to connect on. Will override host and port if defined.
39
+ # There is no unix socket path by default.
40
+ config :path, :validate => :string
41
+
42
+ # The Redis database number.
43
+ config :db, :validate => :number, :default => 0
44
+
45
+ # Initial connection timeout in seconds.
46
+ config :timeout, :validate => :number, :default => 5
47
+
48
+ # Password to authenticate with. There is no authentication by default.
49
+ config :password, :validate => :password
50
+
51
+ # The name of a Redis list or channel.
52
+ config :key, :validate => :string, :required => true
53
+
54
+ # Specify either list or channel. If `data_type` is `list`, then we will BLPOP the
55
+ # key. If `data_type` is `channel`, then we will SUBSCRIBE to the key.
56
+ # If `data_type` is `pattern_channel`, then we will PSUBSCRIBE to the key.
57
+ config :data_type, :validate => [ "list", "channel", "pattern_channel" ], :required => true
58
+
59
+ # The number of events to return from Redis using EVAL.
60
+ config :batch_count, :validate => :number, :default => 125
61
+
62
+ # Redefined Redis commands to be passed to the Redis client.
63
+ config :command_map, :validate => :hash, :default => {}
64
+
65
+ public
66
+
67
+ def register
68
+ host = self.find_redis_node(@key).split(':')
69
+ @logger.info("HOST " + host.to_s)
70
+ @host = host.first
71
+ @port = host.last
72
+ @redis_url = @path.nil? ? "redis://#{@password}@#{@host}:#{@port}/#{@db}" : "#{@password}@#{@path}/#{@db}"
73
+
74
+ # just switch on data_type once
75
+ if @data_type == 'list' || @data_type == 'dummy'
76
+ @run_method = method(:list_runner)
77
+ @stop_method = method(:list_stop)
78
+ elsif @data_type == 'channel'
79
+ @run_method = method(:channel_runner)
80
+ @stop_method = method(:subscribe_stop)
81
+ elsif @data_type == 'pattern_channel'
82
+ @run_method = method(:pattern_channel_runner)
83
+ @stop_method = method(:subscribe_stop)
84
+ end
85
+
86
+ @list_method = batched? ? method(:list_batch_listener) : method(:list_single_listener)
87
+
88
+ @identity = "#{@redis_url} #{@data_type}:#{@key}"
89
+ puts "Identity: " + @identity
90
+ @logger.info("Registering Redis", :identity => @identity)
91
+ end # def register
92
+
93
+ def run(output_queue)
94
+ @run_method.call(output_queue)
95
+ rescue LogStash::ShutdownSignal
96
+ # ignore and quit
97
+ end # def run
98
+
99
+ def stop
100
+ @stop_method.call
101
+ end
102
+
103
+ # private methods -----------------------------
104
+ private
105
+
106
+ def find_redis_node(key)
107
+ username = 'foo'
108
+ password = 'bitnami'
109
+ fixed_hostname = @fixed_hostname
110
+ @logger.info("Initializing plugin")
111
+ begin
112
+ cluster = RedisClient.cluster(nodes: @nodes, fixed_hostname: fixed_hostname).new_client
113
+
114
+ puts "Cluster initialized"
115
+ msg = "Pinging cluster... <= " + cluster.call('PING')
116
+ @logger.info(msg)
117
+ puts msg
118
+
119
+ slot = cluster.call('CLUSTER', 'KEYSLOT', key).to_i
120
+ return cluster.call('CLUSTER', 'NODES').lines
121
+ .map { |line|
122
+ arr = line.split
123
+ { :host => arr[1].split('@').first, :flags => arr[2], :slot => arr.last }
124
+ }
125
+ .filter { |arr| arr[:flags].include? 'master' }
126
+ .map { |arr|
127
+ range = arr[:slot].split '-'
128
+ if !!fixed_hostname
129
+ port = arr[:host].split(':').last
130
+ host = "#{fixed_hostname}:#{port}"
131
+ else
132
+ host = arr[:host]
133
+ end
134
+ { :host => host, :range => Range.new(range.first.to_i, range.last.to_i) }
135
+ }
136
+ .filter { |arr| arr[:range].include? slot }
137
+ .map { |h| h[:host]}
138
+ .first
139
+ rescue Exception => e
140
+ puts e
141
+ puts e.backtrace
142
+ end
143
+ end
144
+
145
+ def batched?
146
+ @batch_count > 1
147
+ end
148
+
149
+ # private
150
+ def is_list_type?
151
+ @data_type == 'list'
152
+ end
153
+
154
+ # private
155
+ def redis_params
156
+ params = {
157
+ :timeout => @timeout,
158
+ :db => @db,
159
+ :password => @password.nil? ? nil : @password.value,
160
+ :ssl => @ssl
161
+ }
162
+
163
+ if @path.nil?
164
+ params[:host] = @host
165
+ params[:port] = @port
166
+ else
167
+ @logger.warn("Parameter 'path' is set, ignoring parameters: 'host' and 'port'")
168
+ params[:path] = @path
169
+ end
170
+
171
+ params
172
+ end
173
+
174
+ def new_redis_instance
175
+ ::Redis.new(redis_params)
176
+ end
177
+
178
+ # private
179
+ def connect
180
+ redis = new_redis_instance
181
+
182
+ # register any renamed Redis commands
183
+ @command_map.each do |name, renamed|
184
+ redis._client.command_map[name.to_sym] = renamed.to_sym
185
+ end
186
+
187
+ load_batch_script(redis) if batched? && is_list_type?
188
+
189
+ redis
190
+ end # def connect
191
+
192
+ # private
193
+ def queue_event(msg, output_queue, channel=nil)
194
+ begin
195
+ @codec.decode(msg) do |event|
196
+ decorate(event)
197
+ event.set("[@metadata][redis_channel]", channel) if !channel.nil?
198
+ output_queue << event
199
+ end
200
+ rescue => e # parse or event creation error
201
+ @logger.error("Failed to create event", :message => msg, :exception => e, :backtrace => e.backtrace);
202
+ end
203
+ end
204
+
205
+ # private
206
+ def list_stop
207
+ redis = @redis # might change during method invocation
208
+ return if redis.nil? || !redis.connected?
209
+
210
+ redis.quit rescue nil # does client.disconnect internally
211
+ # check if input retried while executing
212
+ list_stop unless redis.equal? @redis
213
+ @redis = nil
214
+ end
215
+
216
+ # private
217
+ def list_runner(output_queue)
218
+ while !stop?
219
+ begin
220
+ @redis ||= connect
221
+ @list_method.call(@redis, output_queue)
222
+ rescue => e
223
+ log_error(e)
224
+ retry if reset_for_error_retry(e)
225
+ end
226
+ end
227
+ end
228
+
229
+ def list_batch_listener(redis, output_queue)
230
+ begin
231
+ results = redis.evalsha(@redis_script_sha, [@key], [@batch_count-1])
232
+ results.each do |item|
233
+ queue_event(item, output_queue)
234
+ end
235
+
236
+ if results.size.zero?
237
+ sleep BATCH_EMPTY_SLEEP
238
+ end
239
+
240
+ # Below is a commented-out implementation of 'batch fetch'
241
+ # using pipelined LPOP calls. This in practice has been observed to
242
+ # perform exactly the same in terms of event throughput as
243
+ # the evalsha method. Given that the EVALSHA implementation uses
244
+ # one call to Redis instead of N (where N == @batch_count) calls,
245
+ # I decided to go with the 'evalsha' method of fetching N items
246
+ # from Redis in bulk.
247
+ #redis.pipelined do
248
+ #error, item = redis.lpop(@key)
249
+ #(@batch_count-1).times { redis.lpop(@key) }
250
+ #end.each do |item|
251
+ #queue_event(item, output_queue) if item
252
+ #end
253
+ # --- End commented out implementation of 'batch fetch'
254
+ # further to the above, the LUA script now uses lrange and trim
255
+ # which should further improve the efficiency of the script
256
+ rescue ::Redis::CommandError => e
257
+ if e.to_s =~ /NOSCRIPT/ then
258
+ @logger.warn("Redis may have been restarted, reloading Redis batch EVAL script", :exception => e);
259
+ load_batch_script(redis)
260
+ retry
261
+ else
262
+ raise e
263
+ end
264
+ end
265
+ end
266
+
267
+ def list_single_listener(redis, output_queue)
268
+ item = redis.blpop(@key, 0, :timeout => 1)
269
+ return unless item # from timeout or other conditions
270
+
271
+ # blpop returns the 'key' read from as well as the item result
272
+ # we only care about the result (2nd item in the list).
273
+ queue_event(item.last, output_queue)
274
+ end
275
+
276
+ # private
277
+ def subscribe_stop
278
+ redis = @redis # might change during method invocation
279
+ return if redis.nil? || !redis.connected?
280
+
281
+ if redis.subscribed?
282
+ if @data_type == 'pattern_channel'
283
+ redis.punsubscribe
284
+ else
285
+ redis.unsubscribe
286
+ end
287
+ end
288
+ redis.close rescue nil # does client.disconnect
289
+ # check if input retried while executing
290
+ subscribe_stop unless redis.equal? @redis
291
+ @redis = nil
292
+ end
293
+
294
+ # private
295
+ def redis_runner
296
+ begin
297
+ @redis ||= connect
298
+ yield
299
+ rescue => e
300
+ log_error(e)
301
+ retry if reset_for_error_retry(e)
302
+ end
303
+ end
304
+
305
+ def log_error(e)
306
+ info = { message: e.message, exception: e.class }
307
+ info[:backtrace] = e.backtrace if @logger.debug?
308
+
309
+ case e
310
+ when ::Redis::TimeoutError
311
+ # expected for channels in case no data is available
312
+ @logger.debug("Redis timeout, retrying", info)
313
+ when ::Redis::BaseConnectionError, ::Redis::ProtocolError
314
+ @logger.warn("Redis connection error", info)
315
+ when ::Redis::BaseError
316
+ @logger.error("Redis error", info)
317
+ when ::LogStash::ShutdownSignal
318
+ @logger.debug("Received shutdown signal")
319
+ else
320
+ info[:backtrace] ||= e.backtrace
321
+ @logger.error("Unexpected error", info)
322
+ end
323
+ end
324
+
325
+ # @return [true] if operation is fine to retry
326
+ def reset_for_error_retry(e)
327
+ return if e.is_a?(::LogStash::ShutdownSignal)
328
+
329
+ # Reset the redis variable to trigger reconnect
330
+ @redis = nil
331
+
332
+ Stud.stoppable_sleep(1) { stop? }
333
+ !stop? # retry if not stop-ing
334
+ end
335
+
336
+ # private
337
+ def channel_runner(output_queue)
338
+ redis_runner do
339
+ channel_listener(output_queue)
340
+ end
341
+ end
342
+
343
+ # private
344
+ def channel_listener(output_queue)
345
+ @redis.subscribe(@key) do |on|
346
+ on.subscribe do |channel, count|
347
+ @logger.info("Subscribed", :channel => channel, :count => count)
348
+ end
349
+
350
+ on.message do |channel, message|
351
+ queue_event(message, output_queue, channel)
352
+ end
353
+
354
+ on.unsubscribe do |channel, count|
355
+ @logger.info("Unsubscribed", :channel => channel, :count => count)
356
+ end
357
+ end
358
+ end
359
+
360
+ def pattern_channel_runner(output_queue)
361
+ redis_runner do
362
+ pattern_channel_listener(output_queue)
363
+ end
364
+ end
365
+
366
+ # private
367
+ def pattern_channel_listener(output_queue)
368
+ @redis.psubscribe @key do |on|
369
+ on.psubscribe do |channel, count|
370
+ @logger.info("Subscribed", :channel => channel, :count => count)
371
+ end
372
+
373
+ on.pmessage do |pattern, channel, message|
374
+ queue_event(message, output_queue, channel)
375
+ end
376
+
377
+ on.punsubscribe do |channel, count|
378
+ @logger.info("Unsubscribed", :channel => channel, :count => count)
379
+ end
380
+ end
381
+ end
382
+
383
+ # private
384
+ def load_batch_script(redis)
385
+ #A Redis Lua EVAL script to fetch a count of keys
386
+ redis_script = <<EOF
387
+ local batchsize = tonumber(ARGV[1])
388
+ local result = redis.call(\'#{@command_map.fetch('lrange', 'lrange')}\', KEYS[1], 0, batchsize)
389
+ redis.call(\'#{@command_map.fetch('ltrim', 'ltrim')}\', KEYS[1], batchsize + 1, -1)
390
+ return result
391
+ EOF
392
+ @redis_script_sha = redis.script(:load, redis_script)
393
+ end
394
+
395
+ end end end # Redis Inputs LogStash
@@ -0,0 +1,31 @@
1
+ Gem::Specification.new do |s|
2
+
3
+ s.name = 'logstash-input-redis_cluster'
4
+ s.version = '1.0.0'
5
+ s.licenses = ['Apache License (2.0)']
6
+ s.summary = "Reads events from a Redis instance"
7
+ s.description = "This gem is a Logstash plugin required to be installed on top of the Logstash core pipeline using $LS_HOME/bin/logstash-plugin install gemname. This gem is not a stand-alone program"
8
+ s.authors = ["Elastic"]
9
+ s.email = 'info@elastic.co'
10
+ s.homepage = "http://www.elastic.co/guide/en/logstash/current/index.html"
11
+ s.require_paths = ["lib"]
12
+
13
+ # Files
14
+ s.files = Dir["lib/**/*","spec/**/*","*.gemspec","*.md","CONTRIBUTORS","Gemfile","LICENSE","NOTICE.TXT", "vendor/jar-dependencies/**/*.jar", "vendor/jar-dependencies/**/*.rb", "VERSION", "docs/**/*"]
15
+
16
+ # Tests
17
+ s.test_files = s.files.grep(%r{^(test|spec|features)/})
18
+
19
+ # Special flag to let us know this is actually a logstash plugin
20
+ s.metadata = { "logstash_plugin" => "true", "logstash_group" => "input" }
21
+
22
+ # Gem dependencies
23
+ s.add_runtime_dependency "logstash-core-plugin-api", ">= 1.60", "<= 2.99"
24
+
25
+ s.add_runtime_dependency 'logstash-codec-json'
26
+ s.add_runtime_dependency 'redis', '>= 4.0.1', '< 5'
27
+
28
+ s.add_development_dependency 'logstash-devutils', '>= 0.0.16'
29
+ s.add_development_dependency 'redis-cluster-client'
30
+ end
31
+