sbmt-kafka_consumer 2.0.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.rspec +3 -0
- data/.rubocop.yml +34 -0
- data/Appraisals +23 -0
- data/CHANGELOG.md +292 -0
- data/Gemfile +5 -0
- data/LICENSE +21 -0
- data/README.md +296 -0
- data/Rakefile +12 -0
- data/config.ru +9 -0
- data/dip.yml +84 -0
- data/docker-compose.yml +68 -0
- data/exe/kafka_consumer +16 -0
- data/lefthook-local.dip_example.yml +4 -0
- data/lefthook.yml +6 -0
- data/lib/generators/kafka_consumer/concerns/configuration.rb +30 -0
- data/lib/generators/kafka_consumer/consumer/USAGE +24 -0
- data/lib/generators/kafka_consumer/consumer/consumer_generator.rb +41 -0
- data/lib/generators/kafka_consumer/consumer/templates/consumer.rb.erb +9 -0
- data/lib/generators/kafka_consumer/consumer/templates/consumer_group.yml.erb +13 -0
- data/lib/generators/kafka_consumer/inbox_consumer/USAGE +22 -0
- data/lib/generators/kafka_consumer/inbox_consumer/inbox_consumer_generator.rb +48 -0
- data/lib/generators/kafka_consumer/inbox_consumer/templates/consumer_group.yml.erb +22 -0
- data/lib/generators/kafka_consumer/install/USAGE +9 -0
- data/lib/generators/kafka_consumer/install/install_generator.rb +22 -0
- data/lib/generators/kafka_consumer/install/templates/Kafkafile +3 -0
- data/lib/generators/kafka_consumer/install/templates/kafka_consumer.yml +59 -0
- data/lib/sbmt/kafka_consumer/app_initializer.rb +13 -0
- data/lib/sbmt/kafka_consumer/base_consumer.rb +104 -0
- data/lib/sbmt/kafka_consumer/cli.rb +55 -0
- data/lib/sbmt/kafka_consumer/client_configurer.rb +73 -0
- data/lib/sbmt/kafka_consumer/config/auth.rb +56 -0
- data/lib/sbmt/kafka_consumer/config/consumer.rb +16 -0
- data/lib/sbmt/kafka_consumer/config/consumer_group.rb +9 -0
- data/lib/sbmt/kafka_consumer/config/deserializer.rb +15 -0
- data/lib/sbmt/kafka_consumer/config/kafka.rb +32 -0
- data/lib/sbmt/kafka_consumer/config/metrics.rb +10 -0
- data/lib/sbmt/kafka_consumer/config/probes/endpoints.rb +13 -0
- data/lib/sbmt/kafka_consumer/config/probes/liveness_probe.rb +11 -0
- data/lib/sbmt/kafka_consumer/config/probes/readiness_probe.rb +10 -0
- data/lib/sbmt/kafka_consumer/config/probes.rb +8 -0
- data/lib/sbmt/kafka_consumer/config/topic.rb +14 -0
- data/lib/sbmt/kafka_consumer/config.rb +76 -0
- data/lib/sbmt/kafka_consumer/inbox_consumer.rb +129 -0
- data/lib/sbmt/kafka_consumer/instrumentation/base_monitor.rb +25 -0
- data/lib/sbmt/kafka_consumer/instrumentation/chainable_monitor.rb +31 -0
- data/lib/sbmt/kafka_consumer/instrumentation/listener_helper.rb +47 -0
- data/lib/sbmt/kafka_consumer/instrumentation/liveness_listener.rb +71 -0
- data/lib/sbmt/kafka_consumer/instrumentation/logger_listener.rb +44 -0
- data/lib/sbmt/kafka_consumer/instrumentation/open_telemetry_loader.rb +23 -0
- data/lib/sbmt/kafka_consumer/instrumentation/open_telemetry_tracer.rb +106 -0
- data/lib/sbmt/kafka_consumer/instrumentation/readiness_listener.rb +38 -0
- data/lib/sbmt/kafka_consumer/instrumentation/sentry_tracer.rb +103 -0
- data/lib/sbmt/kafka_consumer/instrumentation/tracer.rb +18 -0
- data/lib/sbmt/kafka_consumer/instrumentation/tracing_monitor.rb +17 -0
- data/lib/sbmt/kafka_consumer/instrumentation/yabeda_metrics_listener.rb +186 -0
- data/lib/sbmt/kafka_consumer/probes/host.rb +75 -0
- data/lib/sbmt/kafka_consumer/probes/probe.rb +33 -0
- data/lib/sbmt/kafka_consumer/railtie.rb +31 -0
- data/lib/sbmt/kafka_consumer/routing/karafka_v1_consumer_mapper.rb +12 -0
- data/lib/sbmt/kafka_consumer/routing/karafka_v2_consumer_mapper.rb +9 -0
- data/lib/sbmt/kafka_consumer/serialization/base_deserializer.rb +19 -0
- data/lib/sbmt/kafka_consumer/serialization/json_deserializer.rb +18 -0
- data/lib/sbmt/kafka_consumer/serialization/null_deserializer.rb +13 -0
- data/lib/sbmt/kafka_consumer/serialization/protobuf_deserializer.rb +27 -0
- data/lib/sbmt/kafka_consumer/server.rb +35 -0
- data/lib/sbmt/kafka_consumer/simple_logging_consumer.rb +11 -0
- data/lib/sbmt/kafka_consumer/testing/shared_contexts/with_sbmt_karafka_consumer.rb +61 -0
- data/lib/sbmt/kafka_consumer/testing.rb +5 -0
- data/lib/sbmt/kafka_consumer/types.rb +15 -0
- data/lib/sbmt/kafka_consumer/version.rb +7 -0
- data/lib/sbmt/kafka_consumer/yabeda_configurer.rb +91 -0
- data/lib/sbmt/kafka_consumer.rb +59 -0
- data/rubocop/rspec.yml +29 -0
- data/sbmt-kafka_consumer.gemspec +70 -0
- metadata +571 -0
@@ -0,0 +1,73 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class Sbmt::KafkaConsumer::ClientConfigurer
|
4
|
+
def self.configure!(**opts)
|
5
|
+
config = Sbmt::KafkaConsumer::Config.new
|
6
|
+
Karafka::App.setup do |karafka_config|
|
7
|
+
karafka_config.monitor = config.monitor_class.classify.constantize.new
|
8
|
+
karafka_config.logger = Sbmt::KafkaConsumer.logger
|
9
|
+
karafka_config.deserializer = config.deserializer_class.classify.constantize.new
|
10
|
+
|
11
|
+
karafka_config.client_id = config.client_id
|
12
|
+
karafka_config.consumer_mapper = config.consumer_mapper_class.classify.constantize.new
|
13
|
+
karafka_config.kafka = config.to_kafka_options
|
14
|
+
|
15
|
+
karafka_config.pause_timeout = config.pause_timeout * 1_000 if config.pause_timeout.present?
|
16
|
+
karafka_config.pause_max_timeout = config.pause_max_timeout * 1_000 if config.pause_max_timeout.present?
|
17
|
+
karafka_config.max_wait_time = config.max_wait_time * 1_000 if config.max_wait_time.present?
|
18
|
+
karafka_config.shutdown_timeout = config.shutdown_timeout * 1_000 if config.shutdown_timeout.present?
|
19
|
+
|
20
|
+
karafka_config.pause_with_exponential_backoff = config.pause_with_exponential_backoff if config.pause_with_exponential_backoff.present?
|
21
|
+
|
22
|
+
karafka_config.concurrency = (opts[:concurrency]) || config.concurrency
|
23
|
+
|
24
|
+
# Do not validate topics naming consistency
|
25
|
+
# see https://github.com/karafka/karafka/wiki/FAQ#why-am-i-seeing-a-needs-to-be-consistent-namespacing-style-error
|
26
|
+
karafka_config.strict_topics_namespacing = false
|
27
|
+
|
28
|
+
# Recreate consumers with each batch. This will allow Rails code reload to work in the
|
29
|
+
# development mode. Otherwise Karafka process would not be aware of code changes
|
30
|
+
karafka_config.consumer_persistence = !Rails.env.development?
|
31
|
+
end
|
32
|
+
|
33
|
+
Karafka.monitor.subscribe(config.logger_listener_class.classify.constantize.new)
|
34
|
+
Karafka.monitor.subscribe(config.metrics_listener_class.classify.constantize.new)
|
35
|
+
|
36
|
+
target_consumer_groups = if opts[:consumer_groups].blank?
|
37
|
+
config.consumer_groups
|
38
|
+
else
|
39
|
+
config.consumer_groups.select do |group|
|
40
|
+
opts[:consumer_groups].include?(group.id)
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
raise "No configured consumer groups found, exiting" if target_consumer_groups.blank?
|
45
|
+
|
46
|
+
# clear routes in case CLI runner tries to reconfigure them
|
47
|
+
# but railtie initializer had already executed and did the same
|
48
|
+
# otherwise we'll get duplicate routes error from sbmt-karafka internal config validation process
|
49
|
+
Karafka::App.routes.clear
|
50
|
+
Karafka::App.routes.draw do
|
51
|
+
target_consumer_groups.each do |cg|
|
52
|
+
consumer_group cg.name do
|
53
|
+
cg.topics.each do |t|
|
54
|
+
topic t.name do
|
55
|
+
active t.active
|
56
|
+
manual_offset_management t.manual_offset_management
|
57
|
+
consumer t.consumer.consumer_klass
|
58
|
+
deserializer t.deserializer.instantiate if t.deserializer.klass.present?
|
59
|
+
kafka t.kafka_options if t.kafka_options.present?
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
def self.routes
|
68
|
+
Karafka::App.routes.map do |cg|
|
69
|
+
topics = cg.topics.map { |t| {name: t.name, deserializer: t.deserializer} }
|
70
|
+
{group: cg.id, topics: topics}
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
@@ -0,0 +1,56 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class Sbmt::KafkaConsumer::Config::Auth < Dry::Struct
|
4
|
+
transform_keys(&:to_sym)
|
5
|
+
|
6
|
+
AVAILABLE_AUTH_KINDS = %w[plaintext sasl_plaintext].freeze
|
7
|
+
DEFAULT_AUTH_KIND = "plaintext"
|
8
|
+
|
9
|
+
AVAILABLE_SASL_MECHANISMS = %w[PLAIN SCRAM-SHA-256 SCRAM-SHA-512].freeze
|
10
|
+
DEFAULT_SASL_MECHANISM = "SCRAM-SHA-512"
|
11
|
+
|
12
|
+
attribute :kind, Sbmt::KafkaConsumer::Types::Strict::String
|
13
|
+
.default(DEFAULT_AUTH_KIND)
|
14
|
+
.enum(*AVAILABLE_AUTH_KINDS)
|
15
|
+
attribute? :sasl_mechanism, Sbmt::KafkaConsumer::Types::Strict::String
|
16
|
+
.default(DEFAULT_SASL_MECHANISM)
|
17
|
+
.enum(*AVAILABLE_SASL_MECHANISMS)
|
18
|
+
attribute? :sasl_username, Sbmt::KafkaConsumer::Types::Strict::String
|
19
|
+
attribute? :sasl_password, Sbmt::KafkaConsumer::Types::Strict::String
|
20
|
+
|
21
|
+
def to_kafka_options
|
22
|
+
ensure_options_are_valid
|
23
|
+
|
24
|
+
opts = {}
|
25
|
+
|
26
|
+
case kind
|
27
|
+
when "sasl_plaintext"
|
28
|
+
opts.merge!(
|
29
|
+
"security.protocol": kind,
|
30
|
+
"sasl.mechanism": sasl_mechanism,
|
31
|
+
"sasl.username": sasl_username,
|
32
|
+
"sasl.password": sasl_password
|
33
|
+
)
|
34
|
+
when "plaintext"
|
35
|
+
opts[:"security.protocol"] = kind
|
36
|
+
else
|
37
|
+
raise Anyway::Config::ValidationError, "unknown auth kind: #{kind}"
|
38
|
+
end
|
39
|
+
|
40
|
+
opts.symbolize_keys
|
41
|
+
end
|
42
|
+
|
43
|
+
private
|
44
|
+
|
45
|
+
def ensure_options_are_valid
|
46
|
+
raise Anyway::Config::ValidationError, "unknown auth kind: #{kind}" unless AVAILABLE_AUTH_KINDS.include?(kind)
|
47
|
+
|
48
|
+
case kind
|
49
|
+
when "sasl_plaintext"
|
50
|
+
raise Anyway::Config::ValidationError, "sasl_username is required for #{kind} auth kind" if sasl_username.blank?
|
51
|
+
raise Anyway::Config::ValidationError, "sasl_password is required for #{kind} auth kind" if sasl_password.blank?
|
52
|
+
raise Anyway::Config::ValidationError, "sasl_mechanism is required for #{kind} auth kind" if sasl_mechanism.blank?
|
53
|
+
raise Anyway::Config::ValidationError, "invalid sasl_mechanism for #{kind} auth kind, available options are: [#{AVAILABLE_SASL_MECHANISMS.join(",")}]" unless AVAILABLE_SASL_MECHANISMS.include?(sasl_mechanism)
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
@@ -0,0 +1,16 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class Sbmt::KafkaConsumer::Config::Consumer < Dry::Struct
|
4
|
+
transform_keys(&:to_sym)
|
5
|
+
|
6
|
+
attribute :klass, Sbmt::KafkaConsumer::Types::Strict::String
|
7
|
+
attribute :init_attrs, Sbmt::KafkaConsumer::Types::ConfigAttrs.optional.default({}.freeze)
|
8
|
+
|
9
|
+
def consumer_klass
|
10
|
+
target_klass = klass.constantize
|
11
|
+
|
12
|
+
return target_klass.consumer_klass if init_attrs.blank?
|
13
|
+
|
14
|
+
target_klass.consumer_klass(**init_attrs)
|
15
|
+
end
|
16
|
+
end
|
@@ -0,0 +1,9 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class Sbmt::KafkaConsumer::Config::ConsumerGroup < Dry::Struct
|
4
|
+
transform_keys(&:to_sym)
|
5
|
+
|
6
|
+
attribute :id, Sbmt::KafkaConsumer::Types::Strict::String
|
7
|
+
attribute :name, Sbmt::KafkaConsumer::Types::Strict::String
|
8
|
+
attribute :topics, Sbmt::KafkaConsumer::Types.Array(Sbmt::KafkaConsumer::Types::ConfigTopic)
|
9
|
+
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class Sbmt::KafkaConsumer::Config::Deserializer < Dry::Struct
|
4
|
+
transform_keys(&:to_sym)
|
5
|
+
|
6
|
+
attribute :klass, Sbmt::KafkaConsumer::Types::Strict::String
|
7
|
+
.optional
|
8
|
+
.default(Sbmt::KafkaConsumer::Serialization::NullDeserializer.to_s.freeze)
|
9
|
+
attribute :init_attrs, Sbmt::KafkaConsumer::Types::ConfigAttrs.optional.default({}.freeze)
|
10
|
+
|
11
|
+
def instantiate
|
12
|
+
return klass.constantize.new if init_attrs.blank?
|
13
|
+
klass.constantize.new(**init_attrs)
|
14
|
+
end
|
15
|
+
end
|
@@ -0,0 +1,32 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class Sbmt::KafkaConsumer::Config::Kafka < Dry::Struct
|
4
|
+
transform_keys(&:to_sym)
|
5
|
+
|
6
|
+
# srv1:port1,srv2:port2,...
|
7
|
+
SERVERS_REGEXP = /^[a-z\d.\-:]+(,[a-z\d.\-:]+)*$/.freeze
|
8
|
+
|
9
|
+
attribute :servers, Sbmt::KafkaConsumer::Types::String.constrained(format: SERVERS_REGEXP)
|
10
|
+
|
11
|
+
# defaults are rdkafka's
|
12
|
+
# see https://github.com/confluentinc/librdkafka/blob/master/CONFIGURATION.md
|
13
|
+
attribute :heartbeat_timeout, Sbmt::KafkaConsumer::Types::Coercible::Integer.optional.default(5)
|
14
|
+
attribute :session_timeout, Sbmt::KafkaConsumer::Types::Coercible::Integer.optional.default(30)
|
15
|
+
attribute :reconnect_timeout, Sbmt::KafkaConsumer::Types::Coercible::Integer.optional.default(3)
|
16
|
+
attribute :connect_timeout, Sbmt::KafkaConsumer::Types::Coercible::Integer.optional.default(5)
|
17
|
+
attribute :socket_timeout, Sbmt::KafkaConsumer::Types::Coercible::Integer.optional.default(30)
|
18
|
+
|
19
|
+
attribute :kafka_options, Sbmt::KafkaConsumer::Types::ConfigAttrs.optional.default({}.freeze)
|
20
|
+
|
21
|
+
def to_kafka_options
|
22
|
+
# root options take precedence over kafka_options' ones
|
23
|
+
kafka_options.merge(
|
24
|
+
"bootstrap.servers": servers,
|
25
|
+
"heartbeat.interval.ms": heartbeat_timeout * 1_000,
|
26
|
+
"session.timeout.ms": session_timeout * 1_000,
|
27
|
+
"reconnect.backoff.max.ms": reconnect_timeout * 1_000,
|
28
|
+
"socket.connection.setup.timeout.ms": connect_timeout * 1_000,
|
29
|
+
"socket.timeout.ms": socket_timeout * 1_000
|
30
|
+
).symbolize_keys
|
31
|
+
end
|
32
|
+
end
|
@@ -0,0 +1,10 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class Sbmt::KafkaConsumer::Config::Metrics < Dry::Struct
|
4
|
+
transform_keys(&:to_sym)
|
5
|
+
|
6
|
+
attribute? :port, Sbmt::KafkaConsumer::Types::Coercible::Integer.optional
|
7
|
+
attribute :path, Sbmt::KafkaConsumer::Types::Strict::String
|
8
|
+
.optional
|
9
|
+
.default("/metrics")
|
10
|
+
end
|
@@ -0,0 +1,13 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class Sbmt::KafkaConsumer::Config::Probes::Endpoints < Dry::Struct
|
4
|
+
transform_keys(&:to_sym)
|
5
|
+
|
6
|
+
attribute :liveness, Sbmt::KafkaConsumer::Config::Probes::LivenessProbe.optional.default(
|
7
|
+
Sbmt::KafkaConsumer::Config::Probes::LivenessProbe.new.freeze
|
8
|
+
)
|
9
|
+
|
10
|
+
attribute :readiness, Sbmt::KafkaConsumer::Config::Probes::ReadinessProbe.optional.default(
|
11
|
+
Sbmt::KafkaConsumer::Config::Probes::ReadinessProbe.new.freeze
|
12
|
+
)
|
13
|
+
end
|
@@ -0,0 +1,11 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class Sbmt::KafkaConsumer::Config::Probes::LivenessProbe < Dry::Struct
|
4
|
+
transform_keys(&:to_sym)
|
5
|
+
|
6
|
+
attribute :enabled, Sbmt::KafkaConsumer::Types::Bool.optional.default(true)
|
7
|
+
attribute :path, Sbmt::KafkaConsumer::Types::Strict::String
|
8
|
+
.optional
|
9
|
+
.default("/liveness")
|
10
|
+
attribute :timeout, Sbmt::KafkaConsumer::Types::Coercible::Integer.optional.default(10)
|
11
|
+
end
|
@@ -0,0 +1,10 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class Sbmt::KafkaConsumer::Config::Probes::ReadinessProbe < Dry::Struct
|
4
|
+
transform_keys(&:to_sym)
|
5
|
+
|
6
|
+
attribute :enabled, Sbmt::KafkaConsumer::Types::Bool.optional.default(true)
|
7
|
+
attribute :path, Sbmt::KafkaConsumer::Types::Strict::String
|
8
|
+
.optional
|
9
|
+
.default("/readiness/kafka_consumer")
|
10
|
+
end
|
@@ -0,0 +1,8 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class Sbmt::KafkaConsumer::Config::Probes < Dry::Struct
|
4
|
+
transform_keys(&:to_sym)
|
5
|
+
|
6
|
+
attribute :port, Sbmt::KafkaConsumer::Types::Coercible::Integer.optional.default(9394)
|
7
|
+
attribute :endpoints, Endpoints.optional.default(Endpoints.new.freeze)
|
8
|
+
end
|
@@ -0,0 +1,14 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class Sbmt::KafkaConsumer::Config::Topic < Dry::Struct
|
4
|
+
transform_keys(&:to_sym)
|
5
|
+
|
6
|
+
attribute :name, Sbmt::KafkaConsumer::Types::Strict::String
|
7
|
+
attribute :consumer, Sbmt::KafkaConsumer::Types::ConfigConsumer
|
8
|
+
attribute :deserializer, Sbmt::KafkaConsumer::Types::ConfigDeserializer
|
9
|
+
.optional
|
10
|
+
.default(Sbmt::KafkaConsumer::Config::Deserializer.new.freeze)
|
11
|
+
attribute :active, Sbmt::KafkaConsumer::Types::Bool.optional.default(true)
|
12
|
+
attribute :manual_offset_management, Sbmt::KafkaConsumer::Types::Bool.optional.default(true)
|
13
|
+
attribute? :kafka_options, Sbmt::KafkaConsumer::Types::ConfigAttrs.optional.default({}.freeze)
|
14
|
+
end
|
@@ -0,0 +1,76 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class Sbmt::KafkaConsumer::Config < Anyway::Config
|
4
|
+
config_name :kafka_consumer
|
5
|
+
|
6
|
+
class << self
|
7
|
+
def coerce_to(struct)
|
8
|
+
lambda do |raw_attrs|
|
9
|
+
struct.new(**raw_attrs)
|
10
|
+
rescue Dry::Types::SchemaError => e
|
11
|
+
raise_validation_error "cannot parse #{struct}: #{e.message}"
|
12
|
+
end
|
13
|
+
end
|
14
|
+
|
15
|
+
def coerce_to_array_of(struct)
|
16
|
+
lambda do |raw_attrs|
|
17
|
+
raw_attrs.keys.map do |obj_title|
|
18
|
+
coerce_to(struct)
|
19
|
+
.call(**raw_attrs.fetch(obj_title)
|
20
|
+
.merge(id: obj_title))
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
attr_config :client_id,
|
27
|
+
:pause_timeout, :pause_max_timeout, :pause_with_exponential_backoff,
|
28
|
+
:max_wait_time, :shutdown_timeout,
|
29
|
+
concurrency: 4, auth: {}, kafka: {}, consumer_groups: {}, probes: {}, metrics: {},
|
30
|
+
deserializer_class: "::Sbmt::KafkaConsumer::Serialization::NullDeserializer",
|
31
|
+
monitor_class: "::Sbmt::KafkaConsumer::Instrumentation::TracingMonitor",
|
32
|
+
logger_class: "::Sbmt::KafkaConsumer::Logger",
|
33
|
+
logger_listener_class: "::Sbmt::KafkaConsumer::Instrumentation::LoggerListener",
|
34
|
+
metrics_listener_class: "::Sbmt::KafkaConsumer::Instrumentation::YabedaMetricsListener",
|
35
|
+
consumer_mapper_class: "::Sbmt::KafkaConsumer::Routing::KarafkaV1ConsumerMapper"
|
36
|
+
|
37
|
+
required :client_id
|
38
|
+
|
39
|
+
on_load :validate_consumer_groups
|
40
|
+
on_load :set_default_metrics_port
|
41
|
+
|
42
|
+
coerce_types client_id: :string,
|
43
|
+
pause_timeout: :integer,
|
44
|
+
pause_max_timeout: :integer,
|
45
|
+
pause_with_exponential_backoff: :boolean,
|
46
|
+
max_wait_time: :integer,
|
47
|
+
shutdown_timeout: :integer,
|
48
|
+
concurrency: :integer
|
49
|
+
|
50
|
+
coerce_types kafka: coerce_to(Kafka)
|
51
|
+
coerce_types auth: coerce_to(Auth)
|
52
|
+
coerce_types probes: coerce_to(Probes)
|
53
|
+
coerce_types metrics: coerce_to(Metrics)
|
54
|
+
coerce_types consumer_groups: coerce_to_array_of(ConsumerGroup)
|
55
|
+
|
56
|
+
def to_kafka_options
|
57
|
+
kafka.to_kafka_options
|
58
|
+
.merge(auth.to_kafka_options)
|
59
|
+
end
|
60
|
+
|
61
|
+
private
|
62
|
+
|
63
|
+
def validate_consumer_groups
|
64
|
+
consumer_groups.each do |cg|
|
65
|
+
raise_validation_error "consumer group #{cg.id} must have at least one topic defined" if cg.topics.blank?
|
66
|
+
cg.topics.each do |t|
|
67
|
+
raise_validation_error "topic #{cg.id}.topics.name[#{t.name}] contains invalid consumer class: no const #{t.consumer.klass} defined" unless t.consumer.klass.safe_constantize
|
68
|
+
raise_validation_error "topic #{cg.id}.topics.name[#{t.name}] contains invalid deserializer class: no const #{t.deserializer.klass} defined" unless t.deserializer&.klass&.safe_constantize
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
def set_default_metrics_port
|
74
|
+
self.metrics = metrics.new(port: probes.port) unless metrics.port
|
75
|
+
end
|
76
|
+
end
|
@@ -0,0 +1,129 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Sbmt
|
4
|
+
module KafkaConsumer
|
5
|
+
class InboxConsumer < BaseConsumer
|
6
|
+
IDEMPOTENCY_HEADER_NAME = "Idempotency-Key"
|
7
|
+
DEFAULT_SOURCE = "KAFKA"
|
8
|
+
|
9
|
+
def self.consumer_klass(inbox_item:, event_name: nil, skip_on_error: false, name: nil)
|
10
|
+
Class.new(self) do
|
11
|
+
const_set(:INBOX_ITEM_CLASS_NAME, inbox_item)
|
12
|
+
const_set(:EVENT_NAME, event_name)
|
13
|
+
const_set(:SKIP_ON_ERROR, skip_on_error)
|
14
|
+
|
15
|
+
def self.name
|
16
|
+
superclass.name
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
def extra_message_attrs(_message)
|
22
|
+
{}
|
23
|
+
end
|
24
|
+
|
25
|
+
private
|
26
|
+
|
27
|
+
def process_message(message)
|
28
|
+
logger.tagged(inbox_name: inbox_name, event_name: event_name) do
|
29
|
+
::Sbmt::KafkaConsumer.monitor.instrument(
|
30
|
+
"consumer.inbox.consumed_one", caller: self,
|
31
|
+
message: message,
|
32
|
+
message_uuid: message_uuid(message),
|
33
|
+
inbox_name: inbox_name,
|
34
|
+
event_name: event_name,
|
35
|
+
status: "success"
|
36
|
+
) do
|
37
|
+
process_inbox_item(message)
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
def process_inbox_item(message)
|
43
|
+
result = Sbmt::Outbox::CreateInboxItem.call(
|
44
|
+
inbox_item_class,
|
45
|
+
attributes: message_attrs(message)
|
46
|
+
)
|
47
|
+
|
48
|
+
if result.failure?
|
49
|
+
raise "Failed consuming message for #{inbox_name}, message_uuid: #{message_uuid(message)}: #{result}"
|
50
|
+
end
|
51
|
+
|
52
|
+
item = result.success
|
53
|
+
item.track_metrics_after_consume if item.respond_to?(:track_metrics_after_consume)
|
54
|
+
rescue ActiveRecord::RecordNotUnique
|
55
|
+
instrument_error("Skipped duplicate message for #{inbox_name}, message_uuid: #{message_uuid(message)}", message, "duplicate")
|
56
|
+
rescue => ex
|
57
|
+
if skip_on_error
|
58
|
+
logger.warn("skipping unprocessable message for #{inbox_name}, message_uuid: #{message_uuid(message)}")
|
59
|
+
instrument_error(ex, message, "skipped")
|
60
|
+
else
|
61
|
+
instrument_error(ex, message)
|
62
|
+
end
|
63
|
+
raise ex
|
64
|
+
end
|
65
|
+
|
66
|
+
def message_attrs(message)
|
67
|
+
attrs = {
|
68
|
+
proto_payload: message.raw_payload,
|
69
|
+
options: {
|
70
|
+
headers: message.metadata.headers.dup,
|
71
|
+
group_id: topic.consumer_group.id,
|
72
|
+
topic: message.metadata.topic,
|
73
|
+
partition: message.metadata.partition,
|
74
|
+
source: DEFAULT_SOURCE
|
75
|
+
}
|
76
|
+
}
|
77
|
+
|
78
|
+
if message_uuid(message)
|
79
|
+
attrs[:uuid] = message_uuid(message)
|
80
|
+
end
|
81
|
+
|
82
|
+
# if message has no uuid, it will be generated later in Sbmt::Outbox::CreateInboxItem
|
83
|
+
|
84
|
+
attrs[:event_key] = if message.metadata.key.present?
|
85
|
+
message.metadata.key
|
86
|
+
elsif inbox_item_class.respond_to?(:event_key)
|
87
|
+
inbox_item_class.event_key(message)
|
88
|
+
else
|
89
|
+
# if message has no partitioning key
|
90
|
+
# set it to something random and monotonically increasing like offset
|
91
|
+
message.offset
|
92
|
+
end
|
93
|
+
|
94
|
+
attrs[:event_name] = event_name if inbox_item_class.has_attribute?(:event_name)
|
95
|
+
|
96
|
+
attrs.merge(extra_message_attrs(message))
|
97
|
+
end
|
98
|
+
|
99
|
+
def message_uuid(message)
|
100
|
+
message.metadata.headers.fetch(IDEMPOTENCY_HEADER_NAME, nil).presence
|
101
|
+
end
|
102
|
+
|
103
|
+
def inbox_item_class
|
104
|
+
@inbox_item_class ||= self.class::INBOX_ITEM_CLASS_NAME.constantize
|
105
|
+
end
|
106
|
+
|
107
|
+
def event_name
|
108
|
+
@event_name ||= self.class::EVENT_NAME
|
109
|
+
end
|
110
|
+
|
111
|
+
def inbox_name
|
112
|
+
inbox_item_class.box_name
|
113
|
+
end
|
114
|
+
|
115
|
+
def instrument_error(error, message, status = "failure")
|
116
|
+
::Sbmt::KafkaConsumer.monitor.instrument(
|
117
|
+
"error.occurred",
|
118
|
+
error: error,
|
119
|
+
caller: self,
|
120
|
+
message: message,
|
121
|
+
inbox_name: inbox_name,
|
122
|
+
event_name: event_name,
|
123
|
+
status: status,
|
124
|
+
type: "consumer.inbox.consume_one"
|
125
|
+
)
|
126
|
+
end
|
127
|
+
end
|
128
|
+
end
|
129
|
+
end
|
@@ -0,0 +1,25 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Sbmt
|
4
|
+
module KafkaConsumer
|
5
|
+
module Instrumentation
|
6
|
+
class BaseMonitor < Karafka::Instrumentation::Monitor
|
7
|
+
# karafka consuming is based around batch-processing
|
8
|
+
# so we need these per-message custom events
|
9
|
+
SBMT_KAFKA_CONSUMER_EVENTS = %w[
|
10
|
+
consumer.consumed_one
|
11
|
+
consumer.inbox.consumed_one
|
12
|
+
].freeze
|
13
|
+
|
14
|
+
def initialize
|
15
|
+
super
|
16
|
+
SBMT_KAFKA_CONSUMER_EVENTS.each { |event_id| notifications_bus.register_event(event_id) }
|
17
|
+
end
|
18
|
+
|
19
|
+
def instrument(_event_id, _payload = EMPTY_HASH, &block)
|
20
|
+
super
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
@@ -0,0 +1,31 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Sbmt
|
4
|
+
module KafkaConsumer
|
5
|
+
module Instrumentation
|
6
|
+
class ChainableMonitor < BaseMonitor
|
7
|
+
attr_reader :monitors
|
8
|
+
|
9
|
+
def initialize(monitors = [])
|
10
|
+
super()
|
11
|
+
|
12
|
+
@monitors = monitors
|
13
|
+
end
|
14
|
+
|
15
|
+
def instrument(event_id, payload = EMPTY_HASH, &block)
|
16
|
+
return super if monitors.empty?
|
17
|
+
|
18
|
+
chain = monitors.map { |monitor| monitor.new(event_id, payload) }
|
19
|
+
traverse_chain = proc do
|
20
|
+
if chain.empty?
|
21
|
+
super
|
22
|
+
else
|
23
|
+
chain.shift.trace(&traverse_chain)
|
24
|
+
end
|
25
|
+
end
|
26
|
+
traverse_chain.call
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
@@ -0,0 +1,47 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Sbmt
|
4
|
+
module KafkaConsumer
|
5
|
+
module Instrumentation
|
6
|
+
module ListenerHelper
|
7
|
+
delegate :logger, to: ::Sbmt::KafkaConsumer
|
8
|
+
|
9
|
+
private
|
10
|
+
|
11
|
+
def consumer_tags(event)
|
12
|
+
message = event[:message]
|
13
|
+
{
|
14
|
+
topic: message.metadata.topic,
|
15
|
+
partition: message.metadata.partition
|
16
|
+
}
|
17
|
+
end
|
18
|
+
|
19
|
+
def inbox_tags(event)
|
20
|
+
{
|
21
|
+
inbox_name: event[:inbox_name],
|
22
|
+
event_name: event[:event_name],
|
23
|
+
status: event[:status]
|
24
|
+
}
|
25
|
+
end
|
26
|
+
|
27
|
+
def error_message(error)
|
28
|
+
if error.respond_to?(:message)
|
29
|
+
error.message
|
30
|
+
elsif error.respond_to?(:failure)
|
31
|
+
error.failure
|
32
|
+
else
|
33
|
+
error.to_s
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
def log_backtrace(error)
|
38
|
+
if error.respond_to?(:backtrace)
|
39
|
+
logger.error(error.backtrace.join("\n"))
|
40
|
+
elsif error.respond_to?(:trace)
|
41
|
+
logger.error(error.trace)
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
@@ -0,0 +1,71 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Sbmt
|
4
|
+
module KafkaConsumer
|
5
|
+
module Instrumentation
|
6
|
+
class LivenessListener
|
7
|
+
include ListenerHelper
|
8
|
+
include KafkaConsumer::Probes::Probe
|
9
|
+
|
10
|
+
def initialize(timeout_sec: 10)
|
11
|
+
@consumer_groups = Karafka::App.routes.map(&:name)
|
12
|
+
@timeout_sec = timeout_sec
|
13
|
+
@polls = {}
|
14
|
+
|
15
|
+
setup_subscription
|
16
|
+
end
|
17
|
+
|
18
|
+
def probe(_env)
|
19
|
+
now = current_time
|
20
|
+
timed_out_polls = select_timed_out_polls(now)
|
21
|
+
return probe_ok groups: meta_from_polls(polls, now) if timed_out_polls.empty?
|
22
|
+
|
23
|
+
probe_error failed_groups: meta_from_polls(timed_out_polls, now)
|
24
|
+
end
|
25
|
+
|
26
|
+
def on_connection_listener_fetch_loop(event)
|
27
|
+
consumer_group = event.payload[:subscription_group].consumer_group
|
28
|
+
polls[consumer_group.name] = current_time
|
29
|
+
end
|
30
|
+
|
31
|
+
private
|
32
|
+
|
33
|
+
attr_reader :polls, :timeout_sec, :consumer_groups
|
34
|
+
|
35
|
+
def current_time
|
36
|
+
Time.now.utc
|
37
|
+
end
|
38
|
+
|
39
|
+
def select_timed_out_polls(now)
|
40
|
+
raise "consumer_groups are empty. Please set them up" if consumer_groups.empty?
|
41
|
+
|
42
|
+
consumer_groups.each_with_object({}) do |group, hash|
|
43
|
+
last_poll_at = polls[group]
|
44
|
+
next if last_poll_at && last_poll_at + timeout_sec >= now
|
45
|
+
|
46
|
+
hash[group] = last_poll_at
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
def meta_from_polls(polls, now)
|
51
|
+
polls.each_with_object({}) do |(group, last_poll_at), hash|
|
52
|
+
if last_poll_at.nil?
|
53
|
+
hash[group] = {had_poll: false}
|
54
|
+
next
|
55
|
+
end
|
56
|
+
|
57
|
+
hash[group] = {
|
58
|
+
had_poll: true,
|
59
|
+
last_poll_at: last_poll_at,
|
60
|
+
seconds_since_last_poll: (now - last_poll_at).to_i
|
61
|
+
}
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
def setup_subscription
|
66
|
+
Karafka::App.monitor.subscribe(self)
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|