phobos 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/.gitignore +14 -0
- data/.rspec +2 -0
- data/.ruby-version +1 -0
- data/Dockerfile +13 -0
- data/Gemfile +4 -0
- data/LICENSE.txt +176 -0
- data/README.md +413 -0
- data/Rakefile +6 -0
- data/bin/console +17 -0
- data/bin/phobos +9 -0
- data/bin/setup +8 -0
- data/circle.yml +27 -0
- data/config/phobos.yml.example +78 -0
- data/examples/handler_saving_events_database.rb +49 -0
- data/examples/handler_using_async_producer.rb +15 -0
- data/examples/publishing_messages_without_consumer.rb.rb +72 -0
- data/lib/phobos.rb +62 -0
- data/lib/phobos/cli.rb +61 -0
- data/lib/phobos/cli/runner.rb +48 -0
- data/lib/phobos/cli/start.rb +47 -0
- data/lib/phobos/echo_handler.rb +9 -0
- data/lib/phobos/errors.rb +4 -0
- data/lib/phobos/executor.rb +83 -0
- data/lib/phobos/handler.rb +23 -0
- data/lib/phobos/instrumentation.rb +21 -0
- data/lib/phobos/listener.rb +153 -0
- data/lib/phobos/producer.rb +122 -0
- data/lib/phobos/version.rb +3 -0
- data/phobos.gemspec +59 -0
- data/utils/create-topic.sh +17 -0
- data/utils/env.sh +11 -0
- data/utils/kafka.sh +43 -0
- data/utils/start-all.sh +9 -0
- data/utils/stop-all.sh +9 -0
- data/utils/zk.sh +36 -0
- metadata +275 -0
@@ -0,0 +1,47 @@
|
|
1
|
+
require 'phobos/cli/runner'
|
2
|
+
|
3
|
+
module Phobos
|
4
|
+
module CLI
|
5
|
+
class Start
|
6
|
+
def initialize(options)
|
7
|
+
@config_file = File.expand_path(options[:config])
|
8
|
+
@boot_file = File.expand_path(options[:boot])
|
9
|
+
end
|
10
|
+
|
11
|
+
def execute
|
12
|
+
validate_config_file!
|
13
|
+
Phobos.configure(config_file)
|
14
|
+
load_boot_file
|
15
|
+
validate_listeners!
|
16
|
+
|
17
|
+
Phobos::CLI::Runner.new.run!
|
18
|
+
end
|
19
|
+
|
20
|
+
private
|
21
|
+
|
22
|
+
attr_reader :config_file, :boot_file
|
23
|
+
|
24
|
+
def validate_config_file!
|
25
|
+
unless File.exist?(config_file)
|
26
|
+
Phobos::CLI.logger.error { Hash(message: "Config file not found (#{config_file})") }
|
27
|
+
exit(1)
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
def validate_listeners!
|
32
|
+
Phobos.config.listeners.collect(&:handler).each do |handler_class|
|
33
|
+
begin
|
34
|
+
handler_class.constantize
|
35
|
+
rescue NameError
|
36
|
+
Phobos::CLI.logger.error { Hash(message: "Handler '#{handler_class}' not defined") }
|
37
|
+
exit(1)
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
def load_boot_file
|
43
|
+
load(boot_file) if File.exist?(boot_file)
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
@@ -0,0 +1,83 @@
|
|
1
|
+
module Phobos
|
2
|
+
class Executor
|
3
|
+
include Phobos::Instrumentation
|
4
|
+
LISTENER_OPTS = %i(handler group_id topic start_from_beginning max_bytes_per_partition).freeze
|
5
|
+
|
6
|
+
def initialize
|
7
|
+
@threads = Concurrent::Array.new
|
8
|
+
@listeners = Phobos.config.listeners.flat_map do |config|
|
9
|
+
handler_class = config.handler.constantize
|
10
|
+
listener_configs = config.to_hash.symbolize_keys
|
11
|
+
max_concurrency = listener_configs[:max_concurrency] || 1
|
12
|
+
max_concurrency.times.map do
|
13
|
+
configs = listener_configs.select {|k| LISTENER_OPTS.include?(k)}
|
14
|
+
Phobos::Listener.new(configs.merge(handler: handler_class))
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
def start
|
20
|
+
@signal_to_stop = false
|
21
|
+
@threads.clear
|
22
|
+
@thread_pool = Concurrent::FixedThreadPool.new(@listeners.size)
|
23
|
+
|
24
|
+
@listeners.each do |listener|
|
25
|
+
@thread_pool.post do
|
26
|
+
thread = Thread.current
|
27
|
+
thread.abort_on_exception = true
|
28
|
+
@threads << thread
|
29
|
+
run_listener(listener)
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
true
|
34
|
+
end
|
35
|
+
|
36
|
+
def stop
|
37
|
+
return if @signal_to_stop
|
38
|
+
instrument('executor.stop') do
|
39
|
+
@signal_to_stop = true
|
40
|
+
@listeners.map(&:stop)
|
41
|
+
@threads.select(&:alive?).each { |thread| thread.wakeup rescue nil }
|
42
|
+
@thread_pool&.shutdown
|
43
|
+
@thread_pool&.wait_for_termination
|
44
|
+
Phobos.logger.info { Hash(message: 'Executor stopped') }
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
private
|
49
|
+
|
50
|
+
def run_listener(listener)
|
51
|
+
retry_count = 0
|
52
|
+
backoff = Phobos.create_exponential_backoff
|
53
|
+
|
54
|
+
begin
|
55
|
+
listener.start
|
56
|
+
rescue Exception => e
|
57
|
+
#
|
58
|
+
# When "listener#start" is interrupted it's safe to assume that the consumer
|
59
|
+
# and the kafka client were properly stopped, it's safe to call start
|
60
|
+
# again
|
61
|
+
#
|
62
|
+
interval = backoff.interval_at(retry_count).round(2)
|
63
|
+
metadata = {
|
64
|
+
listener_id: listener.id,
|
65
|
+
retry_count: retry_count,
|
66
|
+
waiting_time: interval,
|
67
|
+
exception_class: e.class.name,
|
68
|
+
exception_message: e.message,
|
69
|
+
backtrace: e.backtrace
|
70
|
+
}
|
71
|
+
|
72
|
+
instrument('executor.retry_listener_error', metadata) do
|
73
|
+
Phobos.logger.error { Hash(message: "Listener crashed, waiting #{interval}s (#{e.message})").merge(metadata)}
|
74
|
+
sleep interval
|
75
|
+
end
|
76
|
+
|
77
|
+
retry_count += 1
|
78
|
+
retry unless @signal_to_stop
|
79
|
+
end
|
80
|
+
end
|
81
|
+
|
82
|
+
end
|
83
|
+
end
|
@@ -0,0 +1,23 @@
|
|
1
|
+
module Phobos
|
2
|
+
module Handler
|
3
|
+
def self.included(base)
|
4
|
+
base.extend(ClassMethods)
|
5
|
+
end
|
6
|
+
|
7
|
+
def consume(payload, metadata)
|
8
|
+
raise NotImplementedError
|
9
|
+
end
|
10
|
+
|
11
|
+
module ClassMethods
|
12
|
+
def start(kafka_client)
|
13
|
+
end
|
14
|
+
|
15
|
+
def stop
|
16
|
+
end
|
17
|
+
|
18
|
+
def around_consume(payload, metadata)
|
19
|
+
yield
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
module Phobos
|
2
|
+
module Instrumentation
|
3
|
+
NAMESPACE = 'phobos'
|
4
|
+
|
5
|
+
def instrument(event, extra = {})
|
6
|
+
ActiveSupport::Notifications.instrument("#{NAMESPACE}.#{event}", extra) do
|
7
|
+
yield if block_given?
|
8
|
+
end
|
9
|
+
end
|
10
|
+
|
11
|
+
def self.subscribe(event)
|
12
|
+
ActiveSupport::Notifications.subscribe("#{NAMESPACE}.#{event}") do |*args|
|
13
|
+
yield ActiveSupport::Notifications::Event.new(*args) if block_given?
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
def self.unsubscribe(subscriber)
|
18
|
+
ActiveSupport::Notifications.unsubscribe(subscriber)
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
@@ -0,0 +1,153 @@
|
|
1
|
+
module Phobos
|
2
|
+
class Listener
|
3
|
+
include Phobos::Instrumentation
|
4
|
+
|
5
|
+
KAFKA_CONSUMER_OPTS = %i(session_timeout offset_commit_interval offset_commit_threshold heartbeat_interval).freeze
|
6
|
+
DEFAULT_MAX_BYTES_PER_PARTITION = 524288 # 512 KB
|
7
|
+
|
8
|
+
attr_reader :group_id, :topic, :id
|
9
|
+
|
10
|
+
def initialize(handler:, group_id:, topic:, start_from_beginning: true, max_bytes_per_partition: DEFAULT_MAX_BYTES_PER_PARTITION)
|
11
|
+
@id = SecureRandom.hex[0...6]
|
12
|
+
@handler_class = handler
|
13
|
+
@group_id = group_id
|
14
|
+
@topic = topic
|
15
|
+
@subscribe_opts = {
|
16
|
+
start_from_beginning: start_from_beginning,
|
17
|
+
max_bytes_per_partition: max_bytes_per_partition
|
18
|
+
}
|
19
|
+
@kafka_client = Phobos.create_kafka_client
|
20
|
+
@producer_enabled = @handler_class.ancestors.include?(Phobos::Producer)
|
21
|
+
end
|
22
|
+
|
23
|
+
def start
|
24
|
+
@signal_to_stop = false
|
25
|
+
instrument('listener.start', listener_metadata) do
|
26
|
+
@consumer = create_kafka_consumer
|
27
|
+
@consumer.subscribe(topic, @subscribe_opts)
|
28
|
+
|
29
|
+
# This is done here because the producer client is bound to the current thread and
|
30
|
+
# since "start" blocks a thread might be used to call it
|
31
|
+
@handler_class.producer.configure_kafka_client(@kafka_client) if @producer_enabled
|
32
|
+
|
33
|
+
instrument('listener.start_handler', listener_metadata) { @handler_class.start(@kafka_client) }
|
34
|
+
Phobos.logger.info { Hash(message: 'Listener started').merge(listener_metadata) }
|
35
|
+
end
|
36
|
+
|
37
|
+
begin
|
38
|
+
@consumer.each_batch do |batch|
|
39
|
+
batch_metadata = {
|
40
|
+
batch_size: batch.messages.count,
|
41
|
+
partition: batch.partition,
|
42
|
+
offset_lag: batch.offset_lag,
|
43
|
+
# the offset of the most recent message in the partition
|
44
|
+
highwater_mark_offset: batch.highwater_mark_offset
|
45
|
+
}.merge(listener_metadata)
|
46
|
+
|
47
|
+
instrument('listener.process_batch', batch_metadata) do
|
48
|
+
process_batch(batch)
|
49
|
+
Phobos.logger.info { Hash(message: 'Committed offset').merge(batch_metadata) }
|
50
|
+
end
|
51
|
+
|
52
|
+
return if @signal_to_stop
|
53
|
+
end
|
54
|
+
|
55
|
+
# Abort is an exception to prevent the consumer from committing the offset.
|
56
|
+
# Since "listener" had a message being retried while "stop" was called
|
57
|
+
# it's wise to not commit the batch offset to avoid data loss. This will
|
58
|
+
# cause some messages to be reprocessed
|
59
|
+
#
|
60
|
+
rescue Phobos::AbortError
|
61
|
+
instrument('listener.retry_aborted', listener_metadata) do
|
62
|
+
Phobos.logger.info do
|
63
|
+
{message: 'Retry loop aborted, listener is shutting down'}.merge(listener_metadata)
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
ensure
|
69
|
+
instrument('listener.stop', listener_metadata) do
|
70
|
+
instrument('listener.stop_handler', listener_metadata) { @handler_class.stop }
|
71
|
+
|
72
|
+
@consumer&.stop
|
73
|
+
|
74
|
+
if @producer_enabled
|
75
|
+
@handler_class.producer.async_producer_shutdown
|
76
|
+
@handler_class.producer.configure_kafka_client(nil)
|
77
|
+
end
|
78
|
+
|
79
|
+
@kafka_client.close
|
80
|
+
if @signal_to_stop
|
81
|
+
Phobos.logger.info { Hash(message: 'Listener stopped').merge(listener_metadata) }
|
82
|
+
end
|
83
|
+
end
|
84
|
+
end
|
85
|
+
|
86
|
+
def stop
|
87
|
+
return if @signal_to_stop
|
88
|
+
instrument('listener.stopping', listener_metadata) do
|
89
|
+
Phobos.logger.info { Hash(message: 'Listener stopping').merge(listener_metadata) }
|
90
|
+
@consumer&.stop
|
91
|
+
@signal_to_stop = true
|
92
|
+
end
|
93
|
+
end
|
94
|
+
|
95
|
+
private
|
96
|
+
|
97
|
+
def listener_metadata
|
98
|
+
{ listener_id: id, group_id: group_id, topic: topic }
|
99
|
+
end
|
100
|
+
|
101
|
+
def process_batch(batch)
|
102
|
+
batch.messages.each do |message|
|
103
|
+
backoff = Phobos.create_exponential_backoff
|
104
|
+
metadata = listener_metadata.merge(
|
105
|
+
key: message.key,
|
106
|
+
partition: message.partition,
|
107
|
+
offset: message.offset,
|
108
|
+
retry_count: 0
|
109
|
+
)
|
110
|
+
|
111
|
+
begin
|
112
|
+
instrument('listener.process_message', metadata) do
|
113
|
+
process_message(message, metadata)
|
114
|
+
end
|
115
|
+
rescue => e
|
116
|
+
retry_count = metadata[:retry_count]
|
117
|
+
interval = backoff.interval_at(retry_count).round(2)
|
118
|
+
|
119
|
+
error = {
|
120
|
+
waiting_time: interval,
|
121
|
+
exception_class: e.class.name,
|
122
|
+
exception_message: e.message,
|
123
|
+
backtrace: e.backtrace
|
124
|
+
}
|
125
|
+
|
126
|
+
instrument('listener.retry_handler_error', error.merge(metadata)) do
|
127
|
+
Phobos.logger.error do
|
128
|
+
{message: "error processing message, waiting #{interval}s"}.merge(error).merge(metadata)
|
129
|
+
end
|
130
|
+
|
131
|
+
sleep interval
|
132
|
+
metadata.merge!(retry_count: retry_count + 1)
|
133
|
+
end
|
134
|
+
|
135
|
+
raise Phobos::AbortError if @signal_to_stop
|
136
|
+
retry
|
137
|
+
end
|
138
|
+
end
|
139
|
+
end
|
140
|
+
|
141
|
+
def process_message(message, metadata)
|
142
|
+
payload = message.value
|
143
|
+
@handler_class.around_consume(payload, metadata) do
|
144
|
+
@handler_class.new.consume(payload, metadata)
|
145
|
+
end
|
146
|
+
end
|
147
|
+
|
148
|
+
def create_kafka_consumer
|
149
|
+
configs = Phobos.config.consumer_hash.select { |k| KAFKA_CONSUMER_OPTS.include?(k) }
|
150
|
+
@kafka_client.consumer({group_id: group_id}.merge(configs))
|
151
|
+
end
|
152
|
+
end
|
153
|
+
end
|
@@ -0,0 +1,122 @@
|
|
1
|
+
module Phobos
|
2
|
+
module Producer
|
3
|
+
def self.included(base)
|
4
|
+
base.extend(Phobos::Producer::ClassMethods)
|
5
|
+
end
|
6
|
+
|
7
|
+
def producer
|
8
|
+
Phobos::Producer::PublicAPI.new(self)
|
9
|
+
end
|
10
|
+
|
11
|
+
class PublicAPI
|
12
|
+
def initialize(host_obj)
|
13
|
+
@host_obj = host_obj
|
14
|
+
end
|
15
|
+
|
16
|
+
def publish(topic, payload, key = nil)
|
17
|
+
class_producer.publish(topic, payload, key)
|
18
|
+
end
|
19
|
+
|
20
|
+
def async_publish(topic, payload, key = nil)
|
21
|
+
class_producer.async_publish(topic, payload, key)
|
22
|
+
end
|
23
|
+
|
24
|
+
# @param messages [Array(Hash(:topic, :payload, :key))]
|
25
|
+
# e.g.: [
|
26
|
+
# { topic: 'A', payload: 'message-1', key: '1' },
|
27
|
+
# { topic: 'B', payload: 'message-2', key: '2' }
|
28
|
+
# ]
|
29
|
+
#
|
30
|
+
def publish_list(messages)
|
31
|
+
class_producer.publish_list(messages)
|
32
|
+
end
|
33
|
+
|
34
|
+
def async_publish_list(messages)
|
35
|
+
class_producer.async_publish_list(messages)
|
36
|
+
end
|
37
|
+
|
38
|
+
private
|
39
|
+
|
40
|
+
def class_producer
|
41
|
+
@host_obj.class.producer
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
module ClassMethods
|
46
|
+
def producer
|
47
|
+
Phobos::Producer::ClassMethods::PublicAPI.new
|
48
|
+
end
|
49
|
+
|
50
|
+
class PublicAPI
|
51
|
+
NAMESPACE = :phobos_producer_store
|
52
|
+
|
53
|
+
# This method configures the kafka client used with publish operations
|
54
|
+
# performed by the host class
|
55
|
+
#
|
56
|
+
# @param kafka_client [Kafka::Client]
|
57
|
+
#
|
58
|
+
def configure_kafka_client(kafka_client)
|
59
|
+
async_producer_shutdown
|
60
|
+
producer_store[:kafka_client] = kafka_client
|
61
|
+
end
|
62
|
+
|
63
|
+
def kafka_client
|
64
|
+
producer_store[:kafka_client]
|
65
|
+
end
|
66
|
+
|
67
|
+
def publish(topic, payload, key = nil)
|
68
|
+
publish_list([{ topic: topic, payload: payload, key: key }])
|
69
|
+
end
|
70
|
+
|
71
|
+
def publish_list(messages)
|
72
|
+
client = kafka_client || configure_kafka_client(Phobos.create_kafka_client)
|
73
|
+
producer = client.producer(Phobos.config.producer_hash)
|
74
|
+
produce_messages(producer, messages)
|
75
|
+
ensure
|
76
|
+
producer&.shutdown
|
77
|
+
end
|
78
|
+
|
79
|
+
def create_async_producer
|
80
|
+
client = kafka_client || configure_kafka_client(Phobos.create_kafka_client)
|
81
|
+
async_producer = client.async_producer(Phobos.config.producer_hash)
|
82
|
+
producer_store[:async_producer] = async_producer
|
83
|
+
end
|
84
|
+
|
85
|
+
def async_producer
|
86
|
+
producer_store[:async_producer]
|
87
|
+
end
|
88
|
+
|
89
|
+
def async_publish(topic, payload, key = nil)
|
90
|
+
async_publish_list([{ topic: topic, payload: payload, key: key }])
|
91
|
+
end
|
92
|
+
|
93
|
+
def async_publish_list(messages)
|
94
|
+
producer = async_producer || create_async_producer
|
95
|
+
produce_messages(producer, messages)
|
96
|
+
end
|
97
|
+
|
98
|
+
def async_producer_shutdown
|
99
|
+
async_producer&.deliver_messages
|
100
|
+
async_producer&.shutdown
|
101
|
+
producer_store[:async_producer] = nil
|
102
|
+
end
|
103
|
+
|
104
|
+
private
|
105
|
+
|
106
|
+
def produce_messages(producer, messages)
|
107
|
+
messages.each do |message|
|
108
|
+
producer.produce(message[:payload], topic: message[:topic],
|
109
|
+
key: message[:key],
|
110
|
+
partition_key: message[:key]
|
111
|
+
)
|
112
|
+
end
|
113
|
+
producer.deliver_messages
|
114
|
+
end
|
115
|
+
|
116
|
+
def producer_store
|
117
|
+
Thread.current[NAMESPACE] ||= {}
|
118
|
+
end
|
119
|
+
end
|
120
|
+
end
|
121
|
+
end
|
122
|
+
end
|