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