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.
@@ -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,9 @@
1
+ module Phobos
2
+ class EchoHandler
3
+ include Phobos::Handler
4
+
5
+ def consume(message, metadata)
6
+ Phobos.logger.info { Hash(message: message).merge(metadata) }
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,4 @@
1
+ module Phobos
2
+ class Error < StandardError; end
3
+ class AbortError < Error; end
4
+ 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