phobos 1.8.0 → 1.8.1

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.
@@ -1,9 +1,10 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'thor'
2
4
  require 'phobos/cli/start'
3
5
 
4
6
  module Phobos
5
7
  module CLI
6
-
7
8
  def self.logger
8
9
  @logger ||= Logging.logger[self].tap do |l|
9
10
  l.appenders = [Logging.appenders.stdout]
@@ -1,8 +1,9 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Phobos
2
4
  module CLI
3
5
  class Runner
4
-
5
- SIGNALS = %i( INT TERM QUIT ).freeze
6
+ SIGNALS = [:INT, :TERM, :QUIT].freeze
6
7
 
7
8
  def initialize
8
9
  @signal_queue = []
@@ -42,7 +43,6 @@ module Phobos
42
43
  writer.write_nonblock('.')
43
44
  signal_queue << signal
44
45
  end
45
-
46
46
  end
47
47
  end
48
48
  end
@@ -1,17 +1,15 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'phobos/cli/runner'
2
4
 
3
5
  module Phobos
4
6
  module CLI
5
7
  class Start
6
8
  def initialize(options)
7
- unless options[:skip_config]
8
- @config_file = File.expand_path(options[:config])
9
- end
9
+ @config_file = File.expand_path(options[:config]) unless options[:skip_config]
10
10
  @boot_file = File.expand_path(options[:boot])
11
11
 
12
- if options[:listeners]
13
- @listeners_file = File.expand_path(options[:listeners])
14
- end
12
+ @listeners_file = File.expand_path(options[:listeners]) if options[:listeners]
15
13
  end
16
14
 
17
15
  def execute
@@ -22,9 +20,7 @@ module Phobos
22
20
  Phobos.configure(config_file)
23
21
  end
24
22
 
25
- if listeners_file
26
- Phobos.add_listeners(listeners_file)
27
- end
23
+ Phobos.add_listeners(listeners_file) if listeners_file
28
24
 
29
25
  validate_listeners!
30
26
 
@@ -36,37 +32,33 @@ module Phobos
36
32
  attr_reader :config_file, :boot_file, :listeners_file
37
33
 
38
34
  def validate_config_file!
39
- unless File.exist?(config_file)
40
- Phobos::CLI.logger.error { Hash(message: "Config file not found (#{config_file})") }
41
- exit(1)
42
- end
35
+ File.exist?(config_file) || error_exit("Config file not found (#{config_file})")
43
36
  end
44
37
 
45
38
  def validate_listeners!
46
39
  Phobos.config.listeners.each do |listener|
47
- handler_class = listener.handler
40
+ handler = listener.handler
48
41
 
49
- begin
50
- handler_class.constantize
51
- rescue NameError
52
- Phobos::CLI.logger.error { Hash(message: "Handler '#{handler_class}' not defined") }
53
- exit(1)
54
- end
42
+ Object.const_defined?(handler) || error_exit("Handler '#{handler}' not defined")
55
43
 
56
44
  delivery = listener.delivery
57
45
  if delivery.nil?
58
46
  Phobos::CLI.logger.warn do
59
- Hash(message: "Delivery option should be specified, defaulting to 'batch' - specify this option to silence this message")
47
+ Hash(message: "Delivery option should be specified, defaulting to 'batch'"\
48
+ ' - specify this option to silence this message')
60
49
  end
61
50
  elsif !Listener::DELIVERY_OPTS.include?(delivery)
62
- Phobos::CLI.logger.error do
63
- Hash(message: "Invalid delivery option '#{delivery}'. Please specify one of: #{Listener::DELIVERY_OPTS.join(', ')}")
64
- end
65
- exit(1)
51
+ error_exit("Invalid delivery option '#{delivery}'. Please specify one of: "\
52
+ "#{Listener::DELIVERY_OPTS.join(', ')}")
66
53
  end
67
54
  end
68
55
  end
69
56
 
57
+ def error_exit(msg)
58
+ Phobos::CLI.logger.error { Hash(message: msg) }
59
+ exit(1)
60
+ end
61
+
70
62
  def load_boot_file
71
63
  load(boot_file) if File.exist?(boot_file)
72
64
  end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phobos
4
+ module Constants
5
+ LOG_DATE_PATTERN = '%Y-%m-%dT%H:%M:%S:%L%zZ'
6
+
7
+ KAFKA_CONSUMER_OPTS = [
8
+ :session_timeout,
9
+ :offset_commit_interval,
10
+ :offset_commit_threshold,
11
+ :heartbeat_interval,
12
+ :offset_retention_time
13
+ ].freeze
14
+
15
+ LISTENER_OPTS = [
16
+ :handler,
17
+ :group_id,
18
+ :topic,
19
+ :min_bytes,
20
+ :max_wait_time,
21
+ :force_encoding,
22
+ :start_from_beginning,
23
+ :max_bytes_per_partition,
24
+ :backoff,
25
+ :delivery,
26
+ :session_timeout,
27
+ :offset_commit_interval,
28
+ :offset_commit_threshold,
29
+ :heartbeat_interval,
30
+ :offset_retention_time
31
+ ].freeze
32
+ end
33
+ end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  # Please use this with at least the same consideration as you would when using OpenStruct.
2
4
  # Right now we only use this to parse our internal configuration files. It is not meant to
3
5
  # be used on incoming data.
@@ -5,36 +7,33 @@ module Phobos
5
7
  class DeepStruct < OpenStruct
6
8
  # Based on
7
9
  # https://docs.omniref.com/ruby/2.3.0/files/lib/ostruct.rb#line=88
8
- def initialize(hash=nil)
10
+ def initialize(hash = nil)
9
11
  @table = {}
10
12
  @hash_table = {}
11
13
 
12
- if hash
13
- hash.each_pair do |k, v|
14
- k = k.to_sym
15
- @table[k] = to_deep_struct(v)
16
- @hash_table[k] = v
17
- end
14
+ hash&.each_pair do |key, value|
15
+ key = key.to_sym
16
+ @table[key] = to_deep_struct(value)
17
+ @hash_table[key] = value
18
18
  end
19
19
  end
20
20
 
21
21
  def to_h
22
22
  @hash_table.dup
23
23
  end
24
- alias_method :to_hash, :to_h
24
+ alias to_hash to_h
25
25
 
26
26
  private
27
27
 
28
- def to_deep_struct(v)
29
- case v
28
+ def to_deep_struct(value)
29
+ case value
30
30
  when Hash
31
- self.class.new(v)
31
+ self.class.new(value)
32
32
  when Enumerable
33
- v.map { |el| to_deep_struct(el) }
33
+ value.map { |el| to_deep_struct(el) }
34
34
  else
35
- v
35
+ value
36
36
  end
37
37
  end
38
- protected :to_deep_struct
39
38
  end
40
39
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Phobos
2
4
  class EchoHandler
3
5
  include Phobos::Handler
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Phobos
2
4
  class Error < StandardError; end
3
5
  class AbortError < Error; end
@@ -1,23 +1,9 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Phobos
2
4
  class Executor
3
5
  include Phobos::Instrumentation
4
- LISTENER_OPTS = %i(
5
- handler
6
- group_id
7
- topic
8
- min_bytes
9
- max_wait_time
10
- force_encoding
11
- start_from_beginning
12
- max_bytes_per_partition
13
- backoff
14
- delivery
15
- session_timeout
16
- offset_commit_interval
17
- offset_commit_threshold
18
- heartbeat_interval
19
- offset_retention_time
20
- ).freeze
6
+ include Phobos::Log
21
7
 
22
8
  def initialize
23
9
  @threads = Concurrent::Array.new
@@ -26,7 +12,7 @@ module Phobos
26
12
  listener_configs = config.to_hash.deep_symbolize_keys
27
13
  max_concurrency = listener_configs[:max_concurrency] || 1
28
14
  Array.new(max_concurrency).map do
29
- configs = listener_configs.select { |k| LISTENER_OPTS.include?(k) }
15
+ configs = listener_configs.select { |k| Constants::LISTENER_OPTS.include?(k) }
30
16
  Phobos::Listener.new(configs.merge(handler: handler_class))
31
17
  end
32
18
  end
@@ -51,10 +37,17 @@ module Phobos
51
37
 
52
38
  def stop
53
39
  return if @signal_to_stop
40
+
54
41
  instrument('executor.stop') do
55
42
  @signal_to_stop = true
56
43
  @listeners.each(&:stop)
57
- @threads.select(&:alive?).each { |thread| thread.wakeup rescue nil }
44
+ @threads.select(&:alive?).each do |thread|
45
+ begin
46
+ thread.wakeup
47
+ rescue StandardError
48
+ nil
49
+ end
50
+ end
58
51
  @thread_pool&.shutdown
59
52
  @thread_pool&.wait_for_termination
60
53
  Phobos.logger.info { Hash(message: 'Executor stopped') }
@@ -63,44 +56,48 @@ module Phobos
63
56
 
64
57
  private
65
58
 
66
- def error_metadata(e)
59
+ def error_metadata(exception)
67
60
  {
68
- exception_class: e.class.name,
69
- exception_message: e.message,
70
- backtrace: e.backtrace
61
+ exception_class: exception.class.name,
62
+ exception_message: exception.message,
63
+ backtrace: exception.backtrace
71
64
  }
72
65
  end
73
66
 
67
+ # rubocop:disable Lint/RescueException
74
68
  def run_listener(listener)
75
69
  retry_count = 0
76
- backoff = listener.create_exponential_backoff
77
70
 
78
71
  begin
79
72
  listener.start
80
73
  rescue Exception => e
81
- #
82
- # When "listener#start" is interrupted it's safe to assume that the consumer
83
- # and the kafka client were properly stopped, it's safe to call start
84
- # again
85
- #
86
- interval = backoff.interval_at(retry_count).round(2)
87
- metadata = {
88
- listener_id: listener.id,
89
- retry_count: retry_count,
90
- waiting_time: interval
91
- }.merge(error_metadata(e))
92
-
93
- instrument('executor.retry_listener_error', metadata) do
94
- Phobos.logger.error { Hash(message: "Listener crashed, waiting #{interval}s (#{e.message})").merge(metadata)}
95
- sleep interval
96
- end
97
-
74
+ handle_crashed_listener(listener, e, retry_count)
98
75
  retry_count += 1
99
76
  retry unless @signal_to_stop
100
77
  end
101
78
  rescue Exception => e
102
- Phobos.logger.error { Hash(message: "Failed to run listener (#{e.message})").merge(error_metadata(e)) }
79
+ log_error("Failed to run listener (#{e.message})", error_metadata(e))
103
80
  raise e
104
81
  end
82
+ # rubocop:enable Lint/RescueException
83
+
84
+ # When "listener#start" is interrupted it's safe to assume that the consumer
85
+ # and the kafka client were properly stopped, it's safe to call start
86
+ # again
87
+ def handle_crashed_listener(listener, error, retry_count)
88
+ backoff = listener.create_exponential_backoff
89
+ interval = backoff.interval_at(retry_count).round(2)
90
+
91
+ metadata = {
92
+ listener_id: listener.id,
93
+ retry_count: retry_count,
94
+ waiting_time: interval
95
+ }.merge(error_metadata(error))
96
+
97
+ instrument('executor.retry_listener_error', metadata) do
98
+ log_error("Listener crashed, waiting #{interval}s (#{error.message})", metadata)
99
+ sleep interval
100
+ end
101
+ end
105
102
  end
106
103
  end
@@ -1,27 +1,27 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Phobos
2
4
  module Handler
3
5
  def self.included(base)
4
6
  base.extend(ClassMethods)
5
7
  end
6
8
 
7
- def before_consume(payload, metadata)
9
+ def before_consume(payload, _metadata)
8
10
  payload
9
11
  end
10
12
 
11
- def consume(payload, metadata)
13
+ def consume(_payload, _metadata)
12
14
  raise NotImplementedError
13
15
  end
14
16
 
15
- def around_consume(payload, metadata)
17
+ def around_consume(_payload, _metadata)
16
18
  yield
17
19
  end
18
20
 
19
21
  module ClassMethods
20
- def start(kafka_client)
21
- end
22
+ def start(kafka_client); end
22
23
 
23
- def stop
24
- end
24
+ def stop; end
25
25
  end
26
26
  end
27
27
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'active_support/notifications'
2
4
 
3
5
  module Phobos
@@ -15,8 +17,8 @@ module Phobos
15
17
  end
16
18
 
17
19
  def instrument(event, extra = {})
18
- ActiveSupport::Notifications.instrument("#{NAMESPACE}.#{event}", extra) do |extra|
19
- yield(extra) if block_given?
20
+ ActiveSupport::Notifications.instrument("#{NAMESPACE}.#{event}", extra) do |args|
21
+ yield(args) if block_given?
20
22
  end
21
23
  end
22
24
  end
@@ -1,30 +1,24 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Phobos
4
+ # rubocop:disable Metrics/ParameterLists, Metrics/ClassLength
2
5
  class Listener
3
6
  include Phobos::Instrumentation
7
+ include Phobos::Log
4
8
 
5
- KAFKA_CONSUMER_OPTS = %i(
6
- session_timeout
7
- offset_commit_interval
8
- offset_commit_threshold
9
- heartbeat_interval
10
- offset_retention_time
11
- ).freeze
12
-
13
- DEFAULT_MAX_BYTES_PER_PARTITION = 1048576 # 1 MB
9
+ DEFAULT_MAX_BYTES_PER_PARTITION = 1_048_576 # 1 MB
14
10
  DELIVERY_OPTS = %w[batch message].freeze
15
11
 
16
12
  attr_reader :group_id, :topic, :id
17
13
  attr_reader :handler_class, :encoding
18
14
 
19
- def initialize(handler:, group_id:, topic:, min_bytes: nil,
20
- max_wait_time: nil, force_encoding: nil,
21
- start_from_beginning: true, backoff: nil,
22
- delivery: 'batch',
23
- max_bytes_per_partition: DEFAULT_MAX_BYTES_PER_PARTITION,
15
+ # rubocop:disable Metrics/MethodLength
16
+ def initialize(handler:, group_id:, topic:, min_bytes: nil, max_wait_time: nil,
17
+ force_encoding: nil, start_from_beginning: true, backoff: nil,
18
+ delivery: 'batch', max_bytes_per_partition: DEFAULT_MAX_BYTES_PER_PARTITION,
24
19
  session_timeout: nil, offset_commit_interval: nil,
25
20
  heartbeat_interval: nil, offset_commit_threshold: nil,
26
- offset_retention_time: nil
27
- )
21
+ offset_retention_time: nil)
28
22
  @id = SecureRandom.hex[0...6]
29
23
  @handler_class = handler
30
24
  @group_id = group_id
@@ -32,14 +26,11 @@ module Phobos
32
26
  @backoff = backoff
33
27
  @delivery = delivery.to_s
34
28
  @subscribe_opts = {
35
- start_from_beginning: start_from_beginning,
36
- max_bytes_per_partition: max_bytes_per_partition
29
+ start_from_beginning: start_from_beginning, max_bytes_per_partition: max_bytes_per_partition
37
30
  }
38
31
  @kafka_consumer_opts = compact(
39
- session_timeout: session_timeout,
40
- offset_commit_interval: offset_commit_interval,
41
- heartbeat_interval: heartbeat_interval,
42
- offset_retention_time: offset_retention_time,
32
+ session_timeout: session_timeout, offset_retention_time: offset_retention_time,
33
+ offset_commit_interval: offset_commit_interval, heartbeat_interval: heartbeat_interval,
43
34
  offset_commit_threshold: offset_commit_threshold
44
35
  )
45
36
  @encoding = Encoding.const_get(force_encoding.to_sym) if force_encoding
@@ -47,9 +38,59 @@ module Phobos
47
38
  @kafka_client = Phobos.create_kafka_client
48
39
  @producer_enabled = @handler_class.ancestors.include?(Phobos::Producer)
49
40
  end
41
+ # rubocop:enable Metrics/MethodLength
50
42
 
51
43
  def start
52
44
  @signal_to_stop = false
45
+
46
+ start_listener
47
+
48
+ begin
49
+ start_consumer_loop
50
+ rescue Kafka::ProcessingError, Phobos::AbortError
51
+ # Abort is an exception to prevent the consumer from committing the offset.
52
+ # Since "listener" had a message being retried while "stop" was called
53
+ # it's wise to not commit the batch offset to avoid data loss. This will
54
+ # cause some messages to be reprocessed
55
+ instrument('listener.retry_aborted', listener_metadata) do
56
+ log_info('Retry loop aborted, listener is shutting down', listener_metadata)
57
+ end
58
+ end
59
+ ensure
60
+ stop_listener
61
+ end
62
+
63
+ def stop
64
+ return if should_stop?
65
+
66
+ instrument('listener.stopping', listener_metadata) do
67
+ log_info('Listener stopping', listener_metadata)
68
+ @consumer&.stop
69
+ @signal_to_stop = true
70
+ end
71
+ end
72
+
73
+ def create_exponential_backoff
74
+ Phobos.create_exponential_backoff(@backoff)
75
+ end
76
+
77
+ def should_stop?
78
+ @signal_to_stop == true
79
+ end
80
+
81
+ def send_heartbeat_if_necessary
82
+ raise Phobos::AbortError if should_stop?
83
+
84
+ @consumer&.send_heartbeat_if_necessary
85
+ end
86
+
87
+ private
88
+
89
+ def listener_metadata
90
+ { listener_id: id, group_id: group_id, topic: topic, handler: handler_class.to_s }
91
+ end
92
+
93
+ def start_listener
53
94
  instrument('listener.start', listener_metadata) do
54
95
  @consumer = create_kafka_consumer
55
96
  @consumer.subscribe(topic, @subscribe_opts)
@@ -58,25 +99,14 @@ module Phobos
58
99
  # since "start" blocks a thread might be used to call it
59
100
  @handler_class.producer.configure_kafka_client(@kafka_client) if @producer_enabled
60
101
 
61
- instrument('listener.start_handler', listener_metadata) { @handler_class.start(@kafka_client) }
62
- Phobos.logger.info { Hash(message: 'Listener started').merge(listener_metadata) }
63
- end
64
-
65
- begin
66
- @delivery == 'batch' ? consume_each_batch : consume_each_message
67
-
68
- # Abort is an exception to prevent the consumer from committing the offset.
69
- # Since "listener" had a message being retried while "stop" was called
70
- # it's wise to not commit the batch offset to avoid data loss. This will
71
- # cause some messages to be reprocessed
72
- #
73
- rescue Kafka::ProcessingError, Phobos::AbortError
74
- instrument('listener.retry_aborted', listener_metadata) do
75
- Phobos.logger.info({ message: 'Retry loop aborted, listener is shutting down' }.merge(listener_metadata))
102
+ instrument('listener.start_handler', listener_metadata) do
103
+ @handler_class.start(@kafka_client)
76
104
  end
105
+ log_info('Listener started', listener_metadata)
77
106
  end
107
+ end
78
108
 
79
- ensure
109
+ def stop_listener
80
110
  instrument('listener.stop', listener_metadata) do
81
111
  instrument('listener.stop_handler', listener_metadata) { @handler_class.stop }
82
112
 
@@ -88,12 +118,14 @@ module Phobos
88
118
  end
89
119
 
90
120
  @kafka_client.close
91
- if should_stop?
92
- Phobos.logger.info { Hash(message: 'Listener stopped').merge(listener_metadata) }
93
- end
121
+ log_info('Listener stopped', listener_metadata) if should_stop?
94
122
  end
95
123
  end
96
124
 
125
+ def start_consumer_loop
126
+ @delivery == 'batch' ? consume_each_batch : consume_each_message
127
+ end
128
+
97
129
  def consume_each_batch
98
130
  @consumer.each_batch(@message_processing_opts) do |batch|
99
131
  batch_processor = Phobos::Actions::ProcessBatch.new(
@@ -103,8 +135,8 @@ module Phobos
103
135
  )
104
136
 
105
137
  batch_processor.execute
106
- Phobos.logger.debug { Hash(message: 'Committed offset').merge(batch_processor.metadata) }
107
- return if should_stop?
138
+ log_debug('Committed offset', batch_processor.metadata)
139
+ return nil if should_stop?
108
140
  end
109
141
  end
110
142
 
@@ -117,41 +149,15 @@ module Phobos
117
149
  )
118
150
 
119
151
  message_processor.execute
120
- Phobos.logger.debug { Hash(message: 'Committed offset').merge(message_processor.metadata) }
121
- return if should_stop?
122
- end
123
- end
124
-
125
- def stop
126
- return if should_stop?
127
- instrument('listener.stopping', listener_metadata) do
128
- Phobos.logger.info { Hash(message: 'Listener stopping').merge(listener_metadata) }
129
- @consumer&.stop
130
- @signal_to_stop = true
152
+ log_debug('Committed offset', message_processor.metadata)
153
+ return nil if should_stop?
131
154
  end
132
155
  end
133
156
 
134
- def create_exponential_backoff
135
- Phobos.create_exponential_backoff(@backoff)
136
- end
137
-
138
- def should_stop?
139
- @signal_to_stop == true
140
- end
141
-
142
- def send_heartbeat_if_necessary
143
- raise Phobos::AbortError if should_stop?
144
- @consumer&.send_heartbeat_if_necessary
145
- end
146
-
147
- private
148
-
149
- def listener_metadata
150
- { listener_id: id, group_id: group_id, topic: topic, handler: handler_class.to_s }
151
- end
152
-
153
157
  def create_kafka_consumer
154
- configs = Phobos.config.consumer_hash.select { |k| KAFKA_CONSUMER_OPTS.include?(k) }
158
+ configs = Phobos.config.consumer_hash.select do |k|
159
+ Constants::KAFKA_CONSUMER_OPTS.include?(k)
160
+ end
155
161
  configs.merge!(@kafka_consumer_opts)
156
162
  @kafka_client.consumer({ group_id: group_id }.merge(configs))
157
163
  end
@@ -160,4 +166,5 @@ module Phobos
160
166
  hash.delete_if { |_, v| v.nil? }
161
167
  end
162
168
  end
169
+ # rubocop:enable Metrics/ParameterLists, Metrics/ClassLength
163
170
  end