glass_octopus 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.env +3 -0
- data/.gitignore +9 -0
- data/.yardopts +1 -0
- data/Gemfile +7 -0
- data/Guardfile +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +93 -0
- data/Rakefile +77 -0
- data/bin/guard +16 -0
- data/bin/rake +16 -0
- data/docker-compose.yml +25 -0
- data/example/advanced.rb +35 -0
- data/example/basic.rb +24 -0
- data/example/ruby_kafka.rb +24 -0
- data/glass_octopus.gemspec +34 -0
- data/lib/glass-octopus.rb +1 -0
- data/lib/glass_octopus.rb +50 -0
- data/lib/glass_octopus/application.rb +37 -0
- data/lib/glass_octopus/bounded_executor.rb +53 -0
- data/lib/glass_octopus/builder.rb +115 -0
- data/lib/glass_octopus/configuration.rb +62 -0
- data/lib/glass_octopus/connection/options_invalid.rb +10 -0
- data/lib/glass_octopus/connection/poseidon_adapter.rb +113 -0
- data/lib/glass_octopus/connection/ruby_kafka_adapter.rb +116 -0
- data/lib/glass_octopus/consumer.rb +39 -0
- data/lib/glass_octopus/context.rb +30 -0
- data/lib/glass_octopus/message.rb +4 -0
- data/lib/glass_octopus/middleware.rb +10 -0
- data/lib/glass_octopus/middleware/active_record.rb +21 -0
- data/lib/glass_octopus/middleware/common_logger.rb +31 -0
- data/lib/glass_octopus/middleware/json_parser.rb +42 -0
- data/lib/glass_octopus/middleware/mongoid.rb +19 -0
- data/lib/glass_octopus/middleware/new_relic.rb +32 -0
- data/lib/glass_octopus/middleware/sentry.rb +33 -0
- data/lib/glass_octopus/runner.rb +64 -0
- data/lib/glass_octopus/unit_of_work.rb +24 -0
- data/lib/glass_octopus/version.rb +3 -0
- metadata +187 -0
@@ -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,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
|