glass_octopus 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,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