karafka 1.3.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
- checksums.yaml.gz.sig +2 -0
- data.tar.gz.sig +0 -0
- data/.coditsu/ci.yml +3 -0
- data/.console_irbrc +11 -0
- data/.github/FUNDING.yml +3 -0
- data/.github/ISSUE_TEMPLATE/bug_report.md +50 -0
- data/.github/ISSUE_TEMPLATE/feature_request.md +20 -0
- data/.gitignore +69 -0
- data/.rspec +1 -0
- data/.ruby-gemset +1 -0
- data/.ruby-version +1 -0
- data/.travis.yml +36 -0
- data/CHANGELOG.md +520 -0
- data/CODE_OF_CONDUCT.md +46 -0
- data/CONTRIBUTING.md +41 -0
- data/Gemfile +12 -0
- data/Gemfile.lock +137 -0
- data/MIT-LICENCE +18 -0
- data/README.md +101 -0
- data/bin/karafka +19 -0
- data/certs/mensfeld.pem +25 -0
- data/config/errors.yml +39 -0
- data/karafka.gemspec +44 -0
- data/lib/karafka.rb +71 -0
- data/lib/karafka/app.rb +53 -0
- data/lib/karafka/attributes_map.rb +68 -0
- data/lib/karafka/backends/inline.rb +16 -0
- data/lib/karafka/base_consumer.rb +57 -0
- data/lib/karafka/base_responder.rb +226 -0
- data/lib/karafka/cli.rb +54 -0
- data/lib/karafka/cli/base.rb +78 -0
- data/lib/karafka/cli/console.rb +31 -0
- data/lib/karafka/cli/flow.rb +45 -0
- data/lib/karafka/cli/info.rb +31 -0
- data/lib/karafka/cli/install.rb +64 -0
- data/lib/karafka/cli/server.rb +71 -0
- data/lib/karafka/code_reloader.rb +67 -0
- data/lib/karafka/connection/api_adapter.rb +155 -0
- data/lib/karafka/connection/batch_delegator.rb +51 -0
- data/lib/karafka/connection/builder.rb +16 -0
- data/lib/karafka/connection/client.rb +117 -0
- data/lib/karafka/connection/listener.rb +71 -0
- data/lib/karafka/connection/message_delegator.rb +36 -0
- data/lib/karafka/consumers/callbacks.rb +71 -0
- data/lib/karafka/consumers/includer.rb +63 -0
- data/lib/karafka/consumers/metadata.rb +10 -0
- data/lib/karafka/consumers/responders.rb +24 -0
- data/lib/karafka/consumers/single_params.rb +15 -0
- data/lib/karafka/contracts.rb +10 -0
- data/lib/karafka/contracts/config.rb +21 -0
- data/lib/karafka/contracts/consumer_group.rb +206 -0
- data/lib/karafka/contracts/consumer_group_topic.rb +19 -0
- data/lib/karafka/contracts/responder_usage.rb +54 -0
- data/lib/karafka/contracts/server_cli_options.rb +29 -0
- data/lib/karafka/errors.rb +51 -0
- data/lib/karafka/fetcher.rb +42 -0
- data/lib/karafka/helpers/class_matcher.rb +88 -0
- data/lib/karafka/helpers/config_retriever.rb +46 -0
- data/lib/karafka/helpers/inflector.rb +26 -0
- data/lib/karafka/helpers/multi_delegator.rb +32 -0
- data/lib/karafka/instrumentation/logger.rb +57 -0
- data/lib/karafka/instrumentation/monitor.rb +70 -0
- data/lib/karafka/instrumentation/proctitle_listener.rb +36 -0
- data/lib/karafka/instrumentation/stdout_listener.rb +138 -0
- data/lib/karafka/params/builders/metadata.rb +33 -0
- data/lib/karafka/params/builders/params.rb +36 -0
- data/lib/karafka/params/builders/params_batch.rb +25 -0
- data/lib/karafka/params/metadata.rb +35 -0
- data/lib/karafka/params/params.rb +68 -0
- data/lib/karafka/params/params_batch.rb +61 -0
- data/lib/karafka/patches/ruby_kafka.rb +47 -0
- data/lib/karafka/persistence/client.rb +29 -0
- data/lib/karafka/persistence/consumers.rb +45 -0
- data/lib/karafka/persistence/topics.rb +48 -0
- data/lib/karafka/process.rb +60 -0
- data/lib/karafka/responders/builder.rb +36 -0
- data/lib/karafka/responders/topic.rb +55 -0
- data/lib/karafka/routing/builder.rb +89 -0
- data/lib/karafka/routing/consumer_group.rb +61 -0
- data/lib/karafka/routing/consumer_mapper.rb +34 -0
- data/lib/karafka/routing/proxy.rb +46 -0
- data/lib/karafka/routing/router.rb +29 -0
- data/lib/karafka/routing/topic.rb +62 -0
- data/lib/karafka/routing/topic_mapper.rb +53 -0
- data/lib/karafka/serialization/json/deserializer.rb +27 -0
- data/lib/karafka/serialization/json/serializer.rb +31 -0
- data/lib/karafka/server.rb +83 -0
- data/lib/karafka/setup/config.rb +221 -0
- data/lib/karafka/setup/configurators/water_drop.rb +36 -0
- data/lib/karafka/setup/dsl.rb +21 -0
- data/lib/karafka/status.rb +29 -0
- data/lib/karafka/templates/application_consumer.rb.erb +7 -0
- data/lib/karafka/templates/application_responder.rb.erb +11 -0
- data/lib/karafka/templates/karafka.rb.erb +92 -0
- data/lib/karafka/version.rb +7 -0
- data/log/.gitkeep +0 -0
- metadata +336 -0
- metadata.gz.sig +0 -0
data/karafka.gemspec
ADDED
@@ -0,0 +1,44 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
lib = File.expand_path('lib', __dir__)
|
4
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
5
|
+
|
6
|
+
require 'karafka/version'
|
7
|
+
|
8
|
+
# rubocop:disable Metrics/BlockLength
|
9
|
+
Gem::Specification.new do |spec|
|
10
|
+
spec.name = 'karafka'
|
11
|
+
spec.version = ::Karafka::VERSION
|
12
|
+
spec.platform = Gem::Platform::RUBY
|
13
|
+
spec.authors = ['Maciej Mensfeld', 'Pavlo Vavruk', 'Adam Gwozdowski']
|
14
|
+
spec.email = %w[maciej@coditsu.io pavlo.vavruk@gmail.com adam99g@gmail.com]
|
15
|
+
spec.homepage = 'https://github.com/karafka/karafka'
|
16
|
+
spec.summary = 'Ruby based framework for working with Apache Kafka'
|
17
|
+
spec.description = 'Framework used to simplify Apache Kafka based Ruby applications development'
|
18
|
+
spec.license = 'MIT'
|
19
|
+
|
20
|
+
spec.add_dependency 'dry-configurable', '~> 0.8'
|
21
|
+
spec.add_dependency 'dry-inflector', '~> 0.1'
|
22
|
+
spec.add_dependency 'dry-monitor', '~> 0.3'
|
23
|
+
spec.add_dependency 'dry-validation', '~> 1.2'
|
24
|
+
spec.add_dependency 'envlogic', '~> 1.1'
|
25
|
+
spec.add_dependency 'irb', '~> 1.0'
|
26
|
+
spec.add_dependency 'multi_json', '>= 1.12'
|
27
|
+
spec.add_dependency 'rake', '>= 11.3'
|
28
|
+
spec.add_dependency 'ruby-kafka', '>= 0.7.8'
|
29
|
+
spec.add_dependency 'thor', '~> 0.20'
|
30
|
+
spec.add_dependency 'waterdrop', '~> 1.3.0'
|
31
|
+
spec.add_dependency 'zeitwerk', '~> 2.1'
|
32
|
+
|
33
|
+
spec.required_ruby_version = '>= 2.4.0'
|
34
|
+
|
35
|
+
if $PROGRAM_NAME.end_with?('gem')
|
36
|
+
spec.signing_key = File.expand_path('~/.ssh/gem-private_key.pem')
|
37
|
+
end
|
38
|
+
|
39
|
+
spec.cert_chain = %w[certs/mensfeld.pem]
|
40
|
+
spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(spec)/}) }
|
41
|
+
spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
|
42
|
+
spec.require_paths = %w[lib]
|
43
|
+
end
|
44
|
+
# rubocop:enable Metrics/BlockLength
|
data/lib/karafka.rb
ADDED
@@ -0,0 +1,71 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
%w[
|
4
|
+
English
|
5
|
+
waterdrop
|
6
|
+
kafka
|
7
|
+
envlogic
|
8
|
+
thor
|
9
|
+
forwardable
|
10
|
+
fileutils
|
11
|
+
multi_json
|
12
|
+
dry-configurable
|
13
|
+
dry-validation
|
14
|
+
dry/events/publisher
|
15
|
+
dry/inflector
|
16
|
+
dry/monitor/notifications
|
17
|
+
dry/core/constants
|
18
|
+
zeitwerk
|
19
|
+
].each(&method(:require))
|
20
|
+
|
21
|
+
# Karafka library
|
22
|
+
module Karafka
|
23
|
+
extend Envlogic
|
24
|
+
|
25
|
+
class << self
|
26
|
+
# @return [Logger] logger that we want to use. Will use ::Karafka::Logger by default
|
27
|
+
def logger
|
28
|
+
@logger ||= App.config.logger
|
29
|
+
end
|
30
|
+
|
31
|
+
# @return [::Karafka::Monitor] monitor that we want to use
|
32
|
+
def monitor
|
33
|
+
@monitor ||= App.config.monitor
|
34
|
+
end
|
35
|
+
|
36
|
+
# @return [String] root path of this gem
|
37
|
+
def gem_root
|
38
|
+
Pathname.new(File.expand_path('..', __dir__))
|
39
|
+
end
|
40
|
+
|
41
|
+
# @return [String] Karafka app root path (user application path)
|
42
|
+
def root
|
43
|
+
Pathname.new(ENV['KARAFKA_ROOT_DIR'] || File.dirname(ENV['BUNDLE_GEMFILE']))
|
44
|
+
end
|
45
|
+
|
46
|
+
# @return [String] path to Karafka gem root core
|
47
|
+
def core_root
|
48
|
+
Pathname.new(File.expand_path('karafka', __dir__))
|
49
|
+
end
|
50
|
+
|
51
|
+
# @return [String] path to a default file that contains booting procedure etc
|
52
|
+
# @note By default it is a file called 'karafka.rb' but it can be specified as you wish if you
|
53
|
+
# have Karafka that is merged into a Sinatra/Rails app and karafka.rb is taken.
|
54
|
+
# It will be used for console/consumers/etc
|
55
|
+
# @example Standard only-Karafka case
|
56
|
+
# Karafka.boot_file #=> '/home/app_path/karafka.rb'
|
57
|
+
# @example Non standard case
|
58
|
+
# KARAFKA_BOOT_FILE='/home/app_path/app.rb'
|
59
|
+
# Karafka.boot_file #=> '/home/app_path/app.rb'
|
60
|
+
def boot_file
|
61
|
+
Pathname.new(ENV['KARAFKA_BOOT_FILE'] || File.join(Karafka.root, 'karafka.rb'))
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
Zeitwerk::Loader
|
67
|
+
.for_gem
|
68
|
+
.tap(&:setup)
|
69
|
+
.tap(&:eager_load)
|
70
|
+
|
71
|
+
Kafka::Consumer.prepend(Karafka::Patches::RubyKafka)
|
data/lib/karafka/app.rb
ADDED
@@ -0,0 +1,53 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Karafka
|
4
|
+
# App class
|
5
|
+
class App
|
6
|
+
extend Setup::Dsl
|
7
|
+
|
8
|
+
class << self
|
9
|
+
# Sets up all the internal components and bootstrap whole app
|
10
|
+
# We need to know details about consumers in order to setup components,
|
11
|
+
# that's why we don't setup them after std setup is done
|
12
|
+
# @raise [Karafka::Errors::InvalidConfigurationError] raised when configuration
|
13
|
+
# doesn't match with the config contract
|
14
|
+
def boot!
|
15
|
+
initialize!
|
16
|
+
Setup::Config.validate!
|
17
|
+
Setup::Config.setup_components
|
18
|
+
initialized!
|
19
|
+
end
|
20
|
+
|
21
|
+
# @return [Karafka::Routing::Builder] consumers builder instance
|
22
|
+
def consumer_groups
|
23
|
+
config.internal.routing_builder
|
24
|
+
end
|
25
|
+
|
26
|
+
# Triggers reload of all cached Karafka app components, so we can use in-process
|
27
|
+
# in-development hot code reloading without Karafka process restart
|
28
|
+
def reload
|
29
|
+
Karafka::Persistence::Consumers.clear
|
30
|
+
Karafka::Persistence::Topics.clear
|
31
|
+
config.internal.routing_builder.reload
|
32
|
+
end
|
33
|
+
|
34
|
+
Status.instance_methods(false).each do |delegated|
|
35
|
+
define_method(delegated) do
|
36
|
+
App.config.internal.status.send(delegated)
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
# Methods that should be delegated to Karafka module
|
41
|
+
%i[
|
42
|
+
root
|
43
|
+
env
|
44
|
+
logger
|
45
|
+
monitor
|
46
|
+
].each do |delegated|
|
47
|
+
define_method(delegated) do
|
48
|
+
Karafka.send(delegated)
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
@@ -0,0 +1,68 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Karafka
|
4
|
+
# Both Karafka and Ruby-Kafka contain a lot of settings that can be applied on multiple
|
5
|
+
# levels. In Karafka that is on consumer group and on the topic level. In Ruby-Kafka it
|
6
|
+
# is on consumer, subscription and consumption levels. In order to maintain an order
|
7
|
+
# in managing those settings, this module was created. It contains details on what setting
|
8
|
+
# where should go and which layer (both on Karafka and Ruby-Kafka) is responsible for
|
9
|
+
# setting it and sending it forward
|
10
|
+
# @note Settings presented here cover all the settings that are being used across Karafka
|
11
|
+
module AttributesMap
|
12
|
+
class << self
|
13
|
+
# What settings should go where in ruby-kafka
|
14
|
+
# @return [Hash] hash with proper sections on what to proxy where in Ruby-Kafka
|
15
|
+
# @note All other settings will be passed to Kafka.new method invocation.
|
16
|
+
# All elements in this hash are just edge cases
|
17
|
+
def api_adapter
|
18
|
+
{
|
19
|
+
consumer: %i[
|
20
|
+
session_timeout offset_commit_interval offset_commit_threshold
|
21
|
+
offset_retention_time heartbeat_interval fetcher_max_queue_size
|
22
|
+
],
|
23
|
+
subscribe: %i[start_from_beginning max_bytes_per_partition],
|
24
|
+
consumption: %i[min_bytes max_bytes max_wait_time],
|
25
|
+
pause: %i[pause_timeout pause_max_timeout pause_exponential_backoff],
|
26
|
+
# All the options that are under kafka config namespace, but are not used
|
27
|
+
# directly with kafka api, but from the Karafka user perspective, they are
|
28
|
+
# still related to kafka. They should not be proxied anywhere
|
29
|
+
ignored: %i[reconnect_timeout automatically_mark_as_consumed]
|
30
|
+
}
|
31
|
+
end
|
32
|
+
|
33
|
+
# @return [Array<Symbol>] properties that can be set on a per topic level
|
34
|
+
def topic
|
35
|
+
(api_adapter[:subscribe] + %i[
|
36
|
+
backend
|
37
|
+
name
|
38
|
+
deserializer
|
39
|
+
responder
|
40
|
+
batch_consuming
|
41
|
+
]).uniq
|
42
|
+
end
|
43
|
+
|
44
|
+
# @return [Array<Symbol>] properties that can be set on a per consumer group level
|
45
|
+
# @note Note that there are settings directly extracted from the config kafka namespace
|
46
|
+
# I did this that way, so I won't have to repeat same setting keys over and over again
|
47
|
+
# Thanks to this solution, if any new setting is available for ruby-kafka, we just need
|
48
|
+
# to add it to our configuration class and it will be handled automatically.
|
49
|
+
def consumer_group
|
50
|
+
# @note We don't ignore the api_adapter[:ignored] values as they should be ignored
|
51
|
+
# only when proxying details go ruby-kafka. We use ignored fields internally in karafka
|
52
|
+
ignored_settings = api_adapter[:subscribe]
|
53
|
+
defined_settings = api_adapter.values.flatten
|
54
|
+
karafka_settings = %i[batch_fetching]
|
55
|
+
# This is a dirty and bad hack of dry-configurable to get keys before setting values
|
56
|
+
dynamically_proxied = Karafka::Setup::Config
|
57
|
+
._settings
|
58
|
+
.settings
|
59
|
+
.find { |s| s.name == :kafka }
|
60
|
+
.value
|
61
|
+
.names
|
62
|
+
.to_a
|
63
|
+
|
64
|
+
(defined_settings + dynamically_proxied).uniq + karafka_settings - ignored_settings
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
@@ -0,0 +1,16 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Karafka
|
4
|
+
# Namespace for all different backends Karafka supports
|
5
|
+
module Backends
|
6
|
+
# Backend that just runs stuff asap without any scheduling
|
7
|
+
module Inline
|
8
|
+
private
|
9
|
+
|
10
|
+
# Executes consume code immediately (without enqueuing)
|
11
|
+
def process
|
12
|
+
Karafka.monitor.instrument('backends.inline.process', caller: self) { consume }
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
@@ -0,0 +1,57 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# Karafka module namespace
|
4
|
+
module Karafka
|
5
|
+
# Base consumer from which all Karafka consumers should inherit
|
6
|
+
class BaseConsumer
|
7
|
+
extend Forwardable
|
8
|
+
|
9
|
+
# Allows us to mark messages as consumed for non-automatic mode without having
|
10
|
+
# to use consumer client directly. We do this that way, because most of the people should not
|
11
|
+
# mess with the client instance directly (just in case)
|
12
|
+
%i[
|
13
|
+
mark_as_consumed
|
14
|
+
mark_as_consumed!
|
15
|
+
trigger_heartbeat
|
16
|
+
trigger_heartbeat!
|
17
|
+
].each do |delegated_method_name|
|
18
|
+
def_delegator :client, delegated_method_name
|
19
|
+
|
20
|
+
private delegated_method_name
|
21
|
+
end
|
22
|
+
|
23
|
+
# @return [Karafka::Routing::Topic] topic to which a given consumer is subscribed
|
24
|
+
attr_reader :topic
|
25
|
+
# @return [Karafka::Params:ParamsBatch] current params batch
|
26
|
+
attr_accessor :params_batch
|
27
|
+
|
28
|
+
# Assigns a topic to a consumer and builds up proper consumer functionalities
|
29
|
+
# so that it can cooperate with the topic settings
|
30
|
+
# @param topic [Karafka::Routing::Topic]
|
31
|
+
def initialize(topic)
|
32
|
+
@topic = topic
|
33
|
+
Consumers::Includer.call(self)
|
34
|
+
end
|
35
|
+
|
36
|
+
# Executes the default consumer flow.
|
37
|
+
def call
|
38
|
+
process
|
39
|
+
end
|
40
|
+
|
41
|
+
private
|
42
|
+
|
43
|
+
# @return [Karafka::Connection::Client] messages consuming client that can be used to
|
44
|
+
# commit manually offset or pause / stop consumer based on the business logic
|
45
|
+
def client
|
46
|
+
Persistence::Client.read
|
47
|
+
end
|
48
|
+
|
49
|
+
# Method that will perform business logic and on data received from Kafka (it will consume
|
50
|
+
# the data)
|
51
|
+
# @note This method needs bo be implemented in a subclass. We stub it here as a failover if
|
52
|
+
# someone forgets about it or makes on with typo
|
53
|
+
def consume
|
54
|
+
raise NotImplementedError, 'Implement this in a subclass'
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
@@ -0,0 +1,226 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Karafka
|
4
|
+
# Base responder from which all Karafka responders should inherit
|
5
|
+
# Similar to Rails responders concept. It allows us to design flow from one app to another
|
6
|
+
# by isolating what responses should be sent (and where) based on a given action
|
7
|
+
# It differs from Rails responders in the way it works: in std http request we can have one
|
8
|
+
# response, here we can have unlimited number of them
|
9
|
+
#
|
10
|
+
# It has a simple API for defining where should we respond (and if it is required)
|
11
|
+
#
|
12
|
+
# @example Basic usage (each registered topic is required to be used by default)
|
13
|
+
# class Responder < BaseResponder
|
14
|
+
# topic :new_action
|
15
|
+
#
|
16
|
+
# def respond(data)
|
17
|
+
# respond_to :new_action, data
|
18
|
+
# end
|
19
|
+
# end
|
20
|
+
#
|
21
|
+
# @example Responding to a topic with extra options
|
22
|
+
# class Responder < BaseResponder
|
23
|
+
# topic :new_action
|
24
|
+
#
|
25
|
+
# def respond(data)
|
26
|
+
# respond_to :new_action, data, partition_key: 'thing'
|
27
|
+
# end
|
28
|
+
# end
|
29
|
+
#
|
30
|
+
# @example Marking topic as not required (we won't have to use it)
|
31
|
+
# class Responder < BaseResponder
|
32
|
+
# topic :required_topic
|
33
|
+
# topic :new_action, required: false
|
34
|
+
#
|
35
|
+
# def respond(data)
|
36
|
+
# respond_to :required_topic, data
|
37
|
+
# end
|
38
|
+
# end
|
39
|
+
#
|
40
|
+
# @example Multiple times used topic
|
41
|
+
# class Responder < BaseResponder
|
42
|
+
# topic :required_topic
|
43
|
+
#
|
44
|
+
# def respond(data)
|
45
|
+
# data.each do |subset|
|
46
|
+
# respond_to :required_topic, subset
|
47
|
+
# end
|
48
|
+
# end
|
49
|
+
# end
|
50
|
+
#
|
51
|
+
# @example Specify serializer for a topic
|
52
|
+
# class Responder < BaseResponder
|
53
|
+
# topic :xml_topic, serializer: MyXMLSerializer
|
54
|
+
#
|
55
|
+
# def respond(data)
|
56
|
+
# data.each do |subset|
|
57
|
+
# respond_to :xml_topic, subset
|
58
|
+
# end
|
59
|
+
# end
|
60
|
+
# end
|
61
|
+
#
|
62
|
+
# @example Accept multiple arguments to a respond method
|
63
|
+
# class Responder < BaseResponder
|
64
|
+
# topic :users_actions
|
65
|
+
# topic :articles_viewed
|
66
|
+
#
|
67
|
+
# def respond(user, article)
|
68
|
+
# respond_to :users_actions, user
|
69
|
+
# respond_to :articles_viewed, article
|
70
|
+
# end
|
71
|
+
# end
|
72
|
+
class BaseResponder
|
73
|
+
# Responder usage contract
|
74
|
+
CONTRACT = Karafka::Contracts::ResponderUsage.new.freeze
|
75
|
+
|
76
|
+
private_constant :CONTRACT
|
77
|
+
|
78
|
+
class << self
|
79
|
+
# Definitions of all topics that we want to be able to use in this responder should go here
|
80
|
+
attr_accessor :topics
|
81
|
+
# Contract that we can use to control and/or require some additional details upon options
|
82
|
+
# that are being passed to the producer. This can be in particular useful if we want to make
|
83
|
+
# sure that for example partition_key is always present.
|
84
|
+
attr_accessor :options_contract
|
85
|
+
|
86
|
+
# Registers a topic as on to which we will be able to respond
|
87
|
+
# @param topic_name [Symbol, String] name of topic to which we want to respond
|
88
|
+
# @param options [Hash] hash with optional configuration details
|
89
|
+
def topic(topic_name, options = {})
|
90
|
+
options[:serializer] ||= Karafka::App.config.serializer
|
91
|
+
options[:registered] = true
|
92
|
+
self.topics ||= {}
|
93
|
+
topic_obj = Responders::Topic.new(topic_name, options)
|
94
|
+
self.topics[topic_obj.name] = topic_obj
|
95
|
+
end
|
96
|
+
|
97
|
+
# A simple alias for easier standalone responder usage.
|
98
|
+
# Instead of building it with new.call it allows (in case of using JSON serializer)
|
99
|
+
# to just run it directly from the class level
|
100
|
+
# @param data Anything that we want to respond with
|
101
|
+
# @example Send user data with a responder
|
102
|
+
# UsersCreatedResponder.call(@created_user)
|
103
|
+
def call(*data)
|
104
|
+
# Just in case there were no topics defined for a responder, we initialize with
|
105
|
+
# empty hash not to handle a nil case
|
106
|
+
self.topics ||= {}
|
107
|
+
new.call(*data)
|
108
|
+
end
|
109
|
+
end
|
110
|
+
|
111
|
+
attr_reader :messages_buffer
|
112
|
+
|
113
|
+
# Creates a responder object
|
114
|
+
# @return [Karafka::BaseResponder] base responder descendant responder
|
115
|
+
def initialize
|
116
|
+
@messages_buffer = {}
|
117
|
+
end
|
118
|
+
|
119
|
+
# Performs respond and validates that all the response requirement were met
|
120
|
+
# @param data Anything that we want to respond with
|
121
|
+
# @note We know that validators should be executed also before sending data to topics, however
|
122
|
+
# the implementation gets way more complicated then, that's why we check after everything
|
123
|
+
# was sent using responder
|
124
|
+
# @example Send user data with a responder
|
125
|
+
# UsersCreatedResponder.new.call(@created_user)
|
126
|
+
# @example Send user data with a responder using non default Parser
|
127
|
+
# UsersCreatedResponder.new(MyParser).call(@created_user)
|
128
|
+
def call(*data)
|
129
|
+
respond(*data)
|
130
|
+
validate_usage!
|
131
|
+
validate_options!
|
132
|
+
deliver!
|
133
|
+
end
|
134
|
+
|
135
|
+
private
|
136
|
+
|
137
|
+
# Checks if we met all the topics requirements. It will fail if we didn't send a message to
|
138
|
+
# a registered required topic, etc.
|
139
|
+
def validate_usage!
|
140
|
+
registered_topics = self.class.topics.map do |name, topic|
|
141
|
+
topic.to_h.merge!(
|
142
|
+
usage_count: messages_buffer[name]&.count || 0
|
143
|
+
)
|
144
|
+
end
|
145
|
+
|
146
|
+
used_topics = messages_buffer.map do |name, usage|
|
147
|
+
topic = self.class.topics[name] || Responders::Topic.new(name, registered: false)
|
148
|
+
topic.to_h.merge!(usage_count: usage.count)
|
149
|
+
end
|
150
|
+
|
151
|
+
result = CONTRACT.call(
|
152
|
+
registered_topics: registered_topics,
|
153
|
+
used_topics: used_topics
|
154
|
+
)
|
155
|
+
|
156
|
+
return if result.success?
|
157
|
+
|
158
|
+
raise Karafka::Errors::InvalidResponderUsageError, result.errors.to_h
|
159
|
+
end
|
160
|
+
|
161
|
+
# Checks if we met all the options requirements before sending them to the producer.
|
162
|
+
def validate_options!
|
163
|
+
return true unless self.class.options_contract
|
164
|
+
|
165
|
+
messages_buffer.each_value do |messages_set|
|
166
|
+
messages_set.each do |message_data|
|
167
|
+
result = self.class.options_contract.call(message_data.last)
|
168
|
+
next if result.success?
|
169
|
+
|
170
|
+
raise Karafka::Errors::InvalidResponderMessageOptionsError, result.errors.to_h
|
171
|
+
end
|
172
|
+
end
|
173
|
+
end
|
174
|
+
|
175
|
+
# Takes all the messages from the buffer and delivers them one by one
|
176
|
+
# @note This method is executed after the validation, so we're sure that
|
177
|
+
# what we send is legit and it will go to a proper topics
|
178
|
+
def deliver!
|
179
|
+
messages_buffer.each_value do |data_elements|
|
180
|
+
data_elements.each do |data, options|
|
181
|
+
# We map this topic name, so it will match namespaced/etc topic in Kafka
|
182
|
+
# @note By default will not change topic (if default mapper used)
|
183
|
+
mapped_topic = Karafka::App.config.topic_mapper.outgoing(options[:topic])
|
184
|
+
external_options = options.merge(topic: mapped_topic)
|
185
|
+
producer(options).call(data, external_options)
|
186
|
+
end
|
187
|
+
end
|
188
|
+
end
|
189
|
+
|
190
|
+
# Method that needs to be implemented in a subclass. It should handle responding
|
191
|
+
# on registered topics
|
192
|
+
# @param _data [Object] anything that we want to use to send to Kafka
|
193
|
+
# @raise [NotImplementedError] This method needs to be implemented in a subclass
|
194
|
+
def respond(*_data)
|
195
|
+
raise NotImplementedError, 'Implement this in a subclass'
|
196
|
+
end
|
197
|
+
|
198
|
+
# This method allow us to respond to a single topic with a given data. It can be used
|
199
|
+
# as many times as we need. Especially when we have 1:n flow
|
200
|
+
# @param topic [Symbol, String] topic to which we want to respond
|
201
|
+
# @param data [String, Object] string or object that we want to send
|
202
|
+
# @param options [Hash] options for waterdrop (e.g. partition_key).
|
203
|
+
# @note Respond to does not accept multiple data arguments.
|
204
|
+
def respond_to(topic, data, options = {})
|
205
|
+
# We normalize the format to string, as WaterDrop and Ruby-Kafka support only
|
206
|
+
# string topics
|
207
|
+
topic = topic.to_s
|
208
|
+
|
209
|
+
messages_buffer[topic] ||= []
|
210
|
+
messages_buffer[topic] << [
|
211
|
+
self.class.topics[topic].serializer.call(data),
|
212
|
+
options.merge(topic: topic)
|
213
|
+
]
|
214
|
+
end
|
215
|
+
|
216
|
+
# @param options [Hash] options for waterdrop
|
217
|
+
# @return [Class] WaterDrop producer (sync or async based on the settings)
|
218
|
+
def producer(options)
|
219
|
+
if self.class.topics[options[:topic]].async?
|
220
|
+
WaterDrop::AsyncProducer
|
221
|
+
else
|
222
|
+
WaterDrop::SyncProducer
|
223
|
+
end
|
224
|
+
end
|
225
|
+
end
|
226
|
+
end
|