glass_octopus 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,37 @@
1
+ require "glass_octopus/consumer"
2
+ require "glass_octopus/configuration"
3
+
4
+ module GlassOctopus
5
+ # @api private
6
+ class Application
7
+ attr_reader :config, :processor
8
+
9
+ def initialize(processor)
10
+ @processor = processor
11
+ @config = Configuration.new
12
+ @consumer = nil
13
+
14
+ yield @config
15
+ end
16
+
17
+ def run
18
+ @consumer = Consumer.new(connection, processor, config.executor, logger)
19
+ @consumer.run
20
+ end
21
+
22
+ def shutdown(timeout=nil)
23
+ timeout ||= config.shutdown_timeout
24
+ @consumer.shutdown(timeout) if @consumer
25
+
26
+ nil
27
+ end
28
+
29
+ def logger
30
+ config.logger
31
+ end
32
+
33
+ def connection
34
+ config.connection_adapter.connect
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,53 @@
1
+ require "delegate"
2
+ require "concurrent"
3
+
4
+ module GlassOctopus
5
+ # BoundedExecutor wraps an existing executor implementation and provides
6
+ # throttling for job submission. It delegates every method to the wrapped
7
+ # executor.
8
+ #
9
+ # Implementation is based on the Java Concurrency In Practice book. See:
10
+ # http://jcip.net/listings/BoundedExecutor.java
11
+ #
12
+ # @example
13
+ # pool = BoundedExecutor.new(Concurrent::FixedThreadPool.new(2), 2)
14
+ #
15
+ # pool.post { puts "something time consuming" }
16
+ # pool.post { puts "something time consuming" }
17
+ #
18
+ # # This will block until the other submitted jobs are done.
19
+ # pool.post { puts "something time consuming" }
20
+ #
21
+ class BoundedExecutor < SimpleDelegator
22
+ # @param executor the executor implementation to wrap
23
+ # @param limit [Integer] maximum number of jobs that can be submitted
24
+ def initialize(executor, limit:)
25
+ super(executor)
26
+ @semaphore = Concurrent::Semaphore.new(limit)
27
+ end
28
+
29
+ # Submit a task to the executor for asynchronous processing. If the
30
+ # submission limit is reached {#post} will block until there is a free
31
+ # worker to accept the new task.
32
+ #
33
+ # @param args [Array] arguments to pass to the task
34
+ # @return [Boolean] +true+ if the task was accepted, false otherwise
35
+ def post(*args, &block)
36
+ return false unless running?
37
+
38
+ @semaphore.acquire
39
+ begin
40
+ __getobj__.post(args, block) do |args, block|
41
+ begin
42
+ block.call(*args)
43
+ ensure
44
+ @semaphore.release
45
+ end
46
+ end
47
+ rescue Concurrent::RejectedExecutionError
48
+ @semaphore.release
49
+ raise
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,115 @@
1
+ require "glass_octopus/middleware/common_logger"
2
+
3
+ module GlassOctopus
4
+ # GlassOctopus::Builder is a small DLS to build processing pipelines. It is
5
+ # very similar to Rack::Builder.
6
+ #
7
+ # Middleware can be a class with a similar signature to Rack middleware. The
8
+ # constructor needs to take an +app+ object which is basically the next
9
+ # middleware in the stack and the instance of the class has to respond to
10
+ # +#call(ctx)+.
11
+ #
12
+ # Lambdas/procs can also be used as middleware. In this case the middleware
13
+ # does not have control over the execution of the next middleware: the next
14
+ # one is *always* called.
15
+ #
16
+ # @example
17
+ # require "glass_octopus"
18
+ #
19
+ # app = GlassOctopus::Builder.new do
20
+ # use GlassOctopus::Middleware::CommonLogger
21
+ # use GlassOctopus::Middleware::JsonParser
22
+ #
23
+ # run Proc.new { |ctx|
24
+ # puts "Hello, #{ctx.params['name']}"
25
+ # }
26
+ # end.to_app
27
+ #
28
+ # GlassOctopus.run(app) do |config|
29
+ # # set config here
30
+ # end
31
+ #
32
+ # @example Using lambdas
33
+ #
34
+ # require "glass_octopus"
35
+ #
36
+ # logger = Logger.new("log/example.log")
37
+ #
38
+ # app = GlassOctopus::Builder.new do
39
+ # use lambda { |ctx| ctx.logger = logger }
40
+ #
41
+ # run Proc.new { |ctx|
42
+ # ctx.logger.info "Hello, #{ctx.params['name']}"
43
+ # }
44
+ # end.to_app
45
+ #
46
+ # GlassOctopus.run(app) do |config|
47
+ # # set config here
48
+ # end
49
+ #
50
+ class Builder
51
+ def initialize(&block)
52
+ @entries = []
53
+ @app = ->(ctx) {}
54
+
55
+ instance_eval(&block) if block_given?
56
+ end
57
+
58
+ # Append a middleware to the stack.
59
+ #
60
+ # @param klass [Class, #call] a middleware class or a callable object
61
+ # @param args [Array] arguments to be passed to the klass constructor
62
+ # @param block a block to be passed to klass constructor
63
+ # @return [Builder] returns self so calls are chainable
64
+ def use(klass, *args, &block)
65
+ @entries << Entry.new(klass, args, block)
66
+ self
67
+ end
68
+
69
+ # Sets the final step in the middleware pipeline, essentially the
70
+ # application itself. Takes a parameter that responds to +#call(ctx)+.
71
+ #
72
+ # @param app [#call] the application to process messages
73
+ # @return [Builder] returns self so calls are chainable
74
+ def run(app)
75
+ @app = app
76
+ self
77
+ end
78
+
79
+ # Generate a new middleware stack from the registered entries.
80
+ #
81
+ # @return [#call] the entry point of the middleware stack which is callable
82
+ # and when called runs the whole stack.
83
+ def to_app
84
+ @entries.reverse_each.reduce(@app) do |app, current_entry|
85
+ current_entry.build(app)
86
+ end
87
+ end
88
+
89
+ # Generate the middleware stack and call it with the passed in context.
90
+ #
91
+ # @param context [Context] message context
92
+ # @return [void]
93
+ # @note This method instantiates a new middleware stack on every call.
94
+ def call(context)
95
+ to_app.call(context)
96
+ end
97
+
98
+ # Represents an entry in the middleware stack.
99
+ # @api private
100
+ Entry = Struct.new(:klass, :args, :block) do
101
+ def build(next_middleware)
102
+ if klass.is_a?(Class)
103
+ klass.new(next_middleware, *args, &block)
104
+ elsif klass.respond_to?(:call)
105
+ lambda do |context|
106
+ klass.call(context)
107
+ next_middleware.call(context)
108
+ end
109
+ else
110
+ raise ArgumentError, "Invalid middleware, it must respond to `call`: #{klass.inspect}"
111
+ end
112
+ end
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,62 @@
1
+ require "logger"
2
+ require "concurrent"
3
+
4
+ require "glass_octopus/bounded_executor"
5
+
6
+ module GlassOctopus
7
+ # Configuration for the application.
8
+ #
9
+ # @!attribute [rw] connection_adapter
10
+ # Connection adapter that connects to the Kafka.
11
+ # @!attribute [rw] executor
12
+ # A thread pool executor to process messages concurrently. Defaults to
13
+ # a {BoundedExecutor} with 25 threads.
14
+ # @!attribute [rw] logger
15
+ # A standard library compatible logger for the application. By default it
16
+ # logs to the STDOUT.
17
+ # @!attribute [rw] shutdown_timeout
18
+ # Number of seconds to wait for the processing to finish before shutting down.
19
+ class Configuration
20
+ attr_accessor :connection_adapter,
21
+ :executor,
22
+ :logger,
23
+ :shutdown_timeout
24
+
25
+ def initialize
26
+ self.logger = Logger.new(STDOUT).tap { |l| l.level = Logger::INFO }
27
+ self.executor = default_executor
28
+ self.shutdown_timeout = 10
29
+ end
30
+
31
+ # Creates a new adapter
32
+ #
33
+ # @param type [:poseidon, :ruby_kafka] type of the adapter to use
34
+ # @yield a block to conigure the adapter
35
+ # @yieldparam config configuration object
36
+ #
37
+ # @see PoseidonAdapter
38
+ # @see RubyKafkaAdapter
39
+ def adapter(type, &block)
40
+ self.connection_adapter = build_adapter(type, &block)
41
+ end
42
+
43
+ # @api private
44
+ def default_executor
45
+ BoundedExecutor.new(Concurrent::FixedThreadPool.new(25), limit: 25)
46
+ end
47
+
48
+ # @api private
49
+ def build_adapter(type, &block)
50
+ case type
51
+ when :poseidon
52
+ require "glass_octopus/connection/poseidon_adapter"
53
+ PoseidonAdapter.new(&block)
54
+ when :ruby_kafka
55
+ require "glass_octopus/connection/ruby_kafka_adapter"
56
+ RubyKafkaAdapter.new(&block)
57
+ else
58
+ raise ArgumentError, "Unknown adapter: #{type}"
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,10 @@
1
+ module GlassOctopus
2
+ class OptionsInvalid < StandardError
3
+ attr_reader :errors
4
+
5
+ def initialize(errors)
6
+ super("Invalid consumer options: #{errors.join(", ")}")
7
+ @errors = errors
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,113 @@
1
+ require "ostruct"
2
+ require "poseidon_cluster"
3
+ require "glass_octopus/message"
4
+ require "glass_octopus/connection/options_invalid"
5
+
6
+ module GlassOctopus
7
+ # Connection adapter that uses the {https://github.com/bpot/poseidon poseidon
8
+ # gem} to talk to Kafka 0.8.x. Tested with Kafka 0.8.2.
9
+ #
10
+ # @example
11
+ # adapter = GlassOctopus::PoseidonAdapter.new do |config|
12
+ # config.broker_list = %w[localhost:9092]
13
+ # config.zookeeper_list = %w[localhost:2181]
14
+ # config.topic = "mytopic"
15
+ # config.group = "mygroup"
16
+ #
17
+ # require "logger"
18
+ # config.logger = Logger.new(STDOUT)
19
+ # end
20
+ #
21
+ # adapter.connect.fetch_message do |message|
22
+ # p message
23
+ # end
24
+ class PoseidonAdapter
25
+ # @yield configure poseidon in the yielded block
26
+ # The following configuration values are required:
27
+ #
28
+ # * +broker_list+: list of Kafka broker addresses
29
+ # * +zookeeper_list+: list of Zookeeper addresses
30
+ # * +topic+: name of the topic to subscribe to
31
+ # * +group+: name of the consumer group
32
+ #
33
+ # Any other configuration value is passed to
34
+ # {http://www.rubydoc.info/github/bsm/poseidon_cluster/Poseidon/ConsumerGroup Poseidon::ConsumerGroup}.
35
+ #
36
+ # @raise [OptionsInvalid]
37
+ def initialize
38
+ @poseidon_consumer = nil
39
+ @closed = false
40
+
41
+ config = OpenStruct.new
42
+ yield config
43
+
44
+ @options = config.to_h
45
+ validate_options
46
+ end
47
+
48
+ # Connect to Kafka and Zookeeper, register the consumer group.
49
+ # This also initiates a rebalance in the consumer group.
50
+ def connect
51
+ @closed = false
52
+ @poseidon_consumer = create_consumer_group
53
+ self
54
+ end
55
+
56
+ # Fetch messages from kafka in a loop.
57
+ #
58
+ # @yield messages read from Kafka
59
+ # @yieldparam message [Message] a Kafka message
60
+ def fetch_message
61
+ @poseidon_consumer.fetch_loop do |partition, messages|
62
+ break if closed?
63
+
64
+ messages.each do |message|
65
+ yield build_message(partition, message)
66
+ end
67
+
68
+ # Return true to auto-commit offset to Zookeeper
69
+ true
70
+ end
71
+ end
72
+
73
+ # Close the connection and stop the {#fetch_message} loop.
74
+ def close
75
+ @closed = true
76
+ @poseidon_consumer.close if @poseidon_consumer
77
+ @poseidon_cluster = nil
78
+ end
79
+
80
+ # @api private
81
+ def closed?
82
+ @closed
83
+ end
84
+
85
+ # @api private
86
+ def create_consumer_group
87
+ options = @options.dup
88
+
89
+ Poseidon::ConsumerGroup.new(
90
+ options.delete(:group),
91
+ options.delete(:broker_list),
92
+ options.delete(:zookeeper_list),
93
+ options.delete(:topic),
94
+ { :max_wait_ms => 1000 }.merge(options)
95
+ )
96
+ end
97
+
98
+ # @api private
99
+ def build_message(partition, message)
100
+ GlassOctopus::Message.new(message.topic, partition, message.offset, message.key, message.value)
101
+ end
102
+
103
+ # @api private
104
+ def validate_options
105
+ errors = []
106
+ [:group, :broker_list, :zookeeper_list, :topic].each do |key|
107
+ errors << "Missing key: #{key}" unless @options.key?(key)
108
+ end
109
+
110
+ raise OptionsInvalid.new(errors) if errors.any?
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,116 @@
1
+ require "kafka"
2
+ require "ostruct"
3
+ require "glass_octopus/message"
4
+ require "glass_octopus/connection/options_invalid"
5
+
6
+ module GlassOctopus
7
+ # Connection adapter that uses the {https://github.com/zendesk/ruby-kafka ruby-kafka} gem
8
+ # to talk to Kafka 0.9+.
9
+ #
10
+ # @example
11
+ # adapter = GlassOctopus::RubyKafkaAdapter.new do |kafka_config|
12
+ # kafka_config.broker_list = %w[localhost:9092]
13
+ # kafka_config.topic = "mytopic"
14
+ # kafka_config.group = "mygroup"
15
+ # kafka_config.kafka = { logger: Logger.new(STDOUT) }
16
+ # end
17
+ #
18
+ # adapter.connect.fetch_message do |message|
19
+ # p message
20
+ # end
21
+ #
22
+ class RubyKafkaAdapter
23
+ # A hash that hold the configuration set up in the initializer block.
24
+ # @return [Hash]
25
+ attr_reader :options
26
+
27
+ # @yield configure ruby-kafka in the yielded block.
28
+ #
29
+ # The following configuration values are required:
30
+ #
31
+ # * +broker_list+: list of Kafka broker addresses
32
+ # * +topic+: name of the topic to subscribe to
33
+ # * +group+: name of the consumer group
34
+ #
35
+ # Optional configuration:
36
+ #
37
+ # * +client+: a hash passed on to Kafka.new
38
+ # * +consumer+: a hash passed on to kafka.consumer
39
+ # * +subscription+: a hash passed on to consumer.subscribe
40
+ #
41
+ # Check the ruby-kafka documentation for driver specific configurations.
42
+ #
43
+ # @raise [OptionsInvalid]
44
+ def initialize
45
+ config = OpenStruct.new
46
+ yield config
47
+ @options = config.to_h
48
+ validate_options
49
+
50
+ @kafka = nil
51
+ @consumer = nil
52
+ end
53
+
54
+ # Connect to Kafka and join the consumer group.
55
+ # @return [void]
56
+ def connect
57
+ @kafka = connect_to_kafka
58
+ @consumer = create_consumer(@kafka)
59
+ @consumer.subscribe(
60
+ options.fetch(:topic),
61
+ **options.fetch(:subscription, {})
62
+ )
63
+
64
+ self
65
+ end
66
+
67
+ # Fetch messages from kafka in a loop.
68
+ # @yield messages read from Kafka
69
+ # @yieldparam message [Message] a Kafka message
70
+ def fetch_message
71
+ @consumer.each_message do |fetched_message|
72
+ message = Message.new(
73
+ fetched_message.topic,
74
+ fetched_message.partition,
75
+ fetched_message.offset,
76
+ fetched_message.key,
77
+ fetched_message.value
78
+ )
79
+
80
+ yield message
81
+ end
82
+ end
83
+
84
+ # @api private
85
+ def close
86
+ @consumer.stop
87
+ @kafka.close
88
+ end
89
+
90
+ # @api private
91
+ def connect_to_kafka
92
+ Kafka.new(
93
+ seed_brokers: options.fetch(:broker_list),
94
+ **options.fetch(:client, {})
95
+ )
96
+ end
97
+
98
+ # @api private
99
+ def create_consumer(kafka)
100
+ kafka.consumer(
101
+ group_id: options.fetch(:group),
102
+ **options.fetch(:consumer, {})
103
+ )
104
+ end
105
+
106
+ # @api private
107
+ def validate_options
108
+ errors = []
109
+ [:broker_list, :group, :topic].each do |key|
110
+ errors << "Missing key: #{key}" unless options.key?(key)
111
+ end
112
+
113
+ raise OptionsInvalid.new(errors) if errors.any?
114
+ end
115
+ end
116
+ end