waterdrop 2.6.14 → 2.8.2

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.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/.github/ISSUE_TEMPLATE/bug_report.md +43 -0
  4. data/.github/ISSUE_TEMPLATE/feature_request.md +20 -0
  5. data/.github/workflows/ci.yml +11 -19
  6. data/.ruby-version +1 -1
  7. data/CHANGELOG.md +256 -0
  8. data/Gemfile +1 -0
  9. data/Gemfile.lock +35 -31
  10. data/LICENSE +17 -0
  11. data/README.md +4 -4
  12. data/bin/verify_kafka_warnings +31 -0
  13. data/certs/cert.pem +26 -0
  14. data/config/locales/errors.yml +17 -3
  15. data/docker-compose.yml +1 -3
  16. data/lib/waterdrop/clients/rdkafka.rb +34 -4
  17. data/lib/waterdrop/config.rb +27 -12
  18. data/lib/waterdrop/contracts/config.rb +12 -1
  19. data/lib/waterdrop/contracts/variant.rb +107 -0
  20. data/lib/waterdrop/errors.rb +16 -0
  21. data/lib/waterdrop/instrumentation/callbacks/delivery.rb +11 -0
  22. data/lib/waterdrop/instrumentation/callbacks/error.rb +11 -0
  23. data/lib/waterdrop/instrumentation/callbacks/oauthbearer_token_refresh.rb +49 -0
  24. data/lib/waterdrop/instrumentation/callbacks/statistics.rb +11 -0
  25. data/lib/waterdrop/instrumentation/logger_listener.rb +11 -3
  26. data/lib/waterdrop/instrumentation/notifications.rb +3 -0
  27. data/lib/waterdrop/instrumentation/vendors/datadog/metrics_listener.rb +10 -1
  28. data/lib/waterdrop/producer/buffer.rb +0 -6
  29. data/lib/waterdrop/producer/sync.rb +33 -9
  30. data/lib/waterdrop/producer/transactions.rb +98 -19
  31. data/lib/waterdrop/producer/variant.rb +109 -0
  32. data/lib/waterdrop/producer.rb +155 -92
  33. data/lib/waterdrop/version.rb +1 -1
  34. data/lib/waterdrop.rb +1 -0
  35. data/waterdrop.gemspec +7 -4
  36. data.tar.gz.sig +0 -0
  37. metadata +51 -33
  38. metadata.gz.sig +0 -0
  39. data/MIT-LICENSE +0 -18
  40. data/certs/cert_chain.pem +0 -26
data/docker-compose.yml CHANGED
@@ -1,9 +1,7 @@
1
- version: '2'
2
-
3
1
  services:
4
2
  kafka:
5
3
  container_name: kafka
6
- image: confluentinc/cp-kafka:7.5.3
4
+ image: confluentinc/cp-kafka:7.8.1
7
5
 
8
6
  ports:
9
7
  - 9092:9092
@@ -11,20 +11,50 @@ module WaterDrop
11
11
  # @param producer [WaterDrop::Producer] producer instance with its config, etc
12
12
  # @note We overwrite this that way, because we do not care
13
13
  def new(producer)
14
- config = producer.config.kafka.to_h
14
+ kafka_config = producer.config.kafka.to_h
15
+ monitor = producer.config.monitor
15
16
 
16
- client = ::Rdkafka::Config.new(config).producer
17
+ client = ::Rdkafka::Config.new(kafka_config).producer(native_kafka_auto_start: false)
18
+
19
+ # Register statistics runner for this particular type of callbacks
20
+ ::Karafka::Core::Instrumentation.statistics_callbacks.add(
21
+ producer.id,
22
+ Instrumentation::Callbacks::Statistics.new(producer.id, client.name, monitor)
23
+ )
24
+
25
+ # Register error tracking callback
26
+ ::Karafka::Core::Instrumentation.error_callbacks.add(
27
+ producer.id,
28
+ Instrumentation::Callbacks::Error.new(producer.id, client.name, monitor)
29
+ )
30
+
31
+ # Register oauth bearer refresh for this particular type of callbacks
32
+ ::Karafka::Core::Instrumentation.oauthbearer_token_refresh_callbacks.add(
33
+ producer.id,
34
+ Instrumentation::Callbacks::OauthbearerTokenRefresh.new(client, monitor)
35
+ )
17
36
 
18
37
  # This callback is not global and is per client, thus we do not have to wrap it with a
19
38
  # callbacks manager to make it work
20
39
  client.delivery_callback = Instrumentation::Callbacks::Delivery.new(
21
40
  producer.id,
22
41
  producer.transactional?,
23
- producer.config.monitor
42
+ monitor
24
43
  )
25
44
 
45
+ oauth_listener = producer.config.oauth.token_provider_listener
46
+ # We need to subscribe the oauth listener here because we want it to be ready before
47
+ # any producer callbacks run. In theory because WaterDrop rdkafka producer is lazy loaded
48
+ # we would have enough time to make user subscribe it himself, but then it would not
49
+ # coop with auto-configuration coming from Karafka. The way it is done below, if it is
50
+ # configured it will be subscribed and if not, user always can subscribe it himself as
51
+ # long as it is done prior to first usage
52
+ monitor.subscribe(oauth_listener) if oauth_listener
53
+
54
+ client.start
55
+
26
56
  # Switch to the transactional mode if user provided the transactional id
27
- client.init_transactions if config.key?(:'transactional.id')
57
+ client.init_transactions if kafka_config.key?(:'transactional.id')
28
58
 
29
59
  client
30
60
  end
@@ -12,7 +12,12 @@ module WaterDrop
12
12
  'client.id': 'waterdrop',
13
13
  # emit librdkafka statistics every five seconds. This is used in instrumentation.
14
14
  # When disabled, part of metrics will not be published and available.
15
- 'statistics.interval.ms': 5_000
15
+ 'statistics.interval.ms': 5_000,
16
+ # We set it to a value that is lower than `max_wait_timeout` to have a final verdict upon
17
+ # sync delivery
18
+ 'message.timeout.ms': 50_000,
19
+ # Must be more or equal to `message.timeout.ms` defaults
20
+ 'transaction.timeout.ms': 55_000
16
21
  }.freeze
17
22
 
18
23
  private_constant :KAFKA_DEFAULTS
@@ -25,7 +30,7 @@ module WaterDrop
25
30
  setting(
26
31
  :id,
27
32
  default: false,
28
- constructor: ->(id) { id || SecureRandom.hex(6) }
33
+ constructor: ->(id) { id || "waterdrop-#{SecureRandom.hex(6)}" }
29
34
  )
30
35
  # option [Instance] logger that we want to use
31
36
  # @note Due to how rdkafka works, this setting is global for all the producers
@@ -44,12 +49,8 @@ module WaterDrop
44
49
  # option [Integer] max payload size allowed for delivery to Kafka
45
50
  setting :max_payload_size, default: 1_000_012
46
51
  # option [Integer] Wait that long for the delivery report or raise an error if this takes
47
- # longer than the timeout.
48
- setting :max_wait_timeout, default: 5
49
- # option [Numeric] how long should we wait between re-checks on the availability of the
50
- # delivery report. In a really robust systems, this describes the min-delivery time
51
- # for a single sync message when produced in isolation
52
- setting :wait_timeout, default: 0.005 # 5 milliseconds
52
+ # longer than the timeout ms.
53
+ setting :max_wait_timeout, default: 60_000
53
54
  # option [Boolean] should we upon detecting full librdkafka queue backoff and retry or should
54
55
  # we raise an exception.
55
56
  # When this is set to `true`, upon full queue, we won't raise an error. There will be error
@@ -60,15 +61,21 @@ module WaterDrop
60
61
  # option [Integer] how long (in seconds) should we backoff before a retry when queue is full
61
62
  # The retry will happen with the same message and backoff should give us some time to
62
63
  # dispatch previously buffered messages.
63
- setting :wait_backoff_on_queue_full, default: 0.1
64
- # option [Numeric] how many seconds should we wait with the backoff on queue having space for
64
+ setting :wait_backoff_on_queue_full, default: 100
65
+ # option [Numeric] how many ms should we wait with the backoff on queue having space for
65
66
  # more messages before re-raising the error.
66
- setting :wait_timeout_on_queue_full, default: 10
67
+ setting :wait_timeout_on_queue_full, default: 10_000
68
+ # option [Boolean] should we instrument non-critical, retryable queue full errors
69
+ setting :instrument_on_wait_queue_full, default: true
67
70
  # option [Numeric] How long to wait before retrying a retryable transaction related error
68
- setting :wait_backoff_on_transaction_command, default: 0.5
71
+ setting :wait_backoff_on_transaction_command, default: 500
69
72
  # option [Numeric] How many times to retry a retryable transaction related error before
70
73
  # giving up
71
74
  setting :max_attempts_on_transaction_command, default: 5
75
+ # When a fatal transactional error occurs, should we close and recreate the underlying producer
76
+ # to keep going or should we stop. Since we will open a new instance and the failed transaction
77
+ # anyhow rolls back, we should be able to safely reload.
78
+ setting :reload_on_transaction_fatal_error, default: true
72
79
 
73
80
  # option [Boolean] should we send messages. Setting this to false can be really useful when
74
81
  # testing and or developing because when set to false, won't actually ping Kafka but will
@@ -86,6 +93,14 @@ module WaterDrop
86
93
  constructor: ->(middleware) { middleware || WaterDrop::Middleware.new }
87
94
  )
88
95
 
96
+ # Namespace for oauth related configuration
97
+ setting :oauth do
98
+ # option [false, #call] Listener for using oauth bearer. This listener will be able to
99
+ # get the client name to decide whether to use a single multi-client token refreshing
100
+ # or have separate tokens per instance.
101
+ setting :token_provider_listener, default: false
102
+ end
103
+
89
104
  # Configuration method
90
105
  # @yield Runs a block of code providing a config singleton instance to it
91
106
  # @yieldparam [WaterDrop::Config] WaterDrop config instance
@@ -14,14 +14,25 @@ module WaterDrop
14
14
 
15
15
  required(:id) { |val| val.is_a?(String) && !val.empty? }
16
16
  required(:logger) { |val| !val.nil? }
17
+ required(:monitor) { |val| !val.nil? }
17
18
  required(:deliver) { |val| [true, false].include?(val) }
18
19
  required(:max_payload_size) { |val| val.is_a?(Integer) && val >= 1 }
19
20
  required(:max_wait_timeout) { |val| val.is_a?(Numeric) && val >= 0 }
20
- required(:wait_timeout) { |val| val.is_a?(Numeric) && val.positive? }
21
+ required(:client_class) { |val| !val.nil? }
21
22
  required(:kafka) { |val| val.is_a?(Hash) && !val.empty? }
22
23
  required(:wait_on_queue_full) { |val| [true, false].include?(val) }
24
+ required(:instrument_on_wait_queue_full) { |val| [true, false].include?(val) }
23
25
  required(:wait_backoff_on_queue_full) { |val| val.is_a?(Numeric) && val >= 0 }
24
26
  required(:wait_timeout_on_queue_full) { |val| val.is_a?(Numeric) && val >= 0 }
27
+ required(:wait_backoff_on_transaction_command) { |val| val.is_a?(Numeric) && val >= 0 }
28
+ required(:max_attempts_on_transaction_command) { |val| val.is_a?(Integer) && val >= 1 }
29
+ required(:reload_on_transaction_fatal_error) { |val| [true, false].include?(val) }
30
+
31
+ nested(:oauth) do
32
+ required(:token_provider_listener) do |val|
33
+ val == false || val.respond_to?(:on_oauthbearer_token_refresh)
34
+ end
35
+ end
25
36
 
26
37
  # rdkafka allows both symbols and strings as keys for config but then casts them to strings
27
38
  # This can be confusing, so we expect all keys to be symbolized
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WaterDrop
4
+ module Contracts
5
+ # Variant validator to ensure basic sanity of the variant alteration data
6
+ class Variant < ::Karafka::Core::Contractable::Contract
7
+ # Taken from librdkafka config
8
+ # Those values can be changed on a per topic basis. We do not support experimental or
9
+ # deprecated values. We also do not support settings that would break rdkafka-ruby
10
+ #
11
+ # @see https://karafka.io/docs/Librdkafka-Configuration/#topic-configuration-properties
12
+ TOPIC_CONFIG_KEYS = %i[
13
+ acks
14
+ compression.codec
15
+ compression.level
16
+ compression.type
17
+ delivery.timeout.ms
18
+ message.timeout.ms
19
+ partitioner
20
+ request.required.acks
21
+ request.timeout.ms
22
+ ].freeze
23
+
24
+ # Boolean values
25
+ BOOLEANS = [true, false].freeze
26
+
27
+ private_constant :TOPIC_CONFIG_KEYS, :BOOLEANS
28
+
29
+ configure do |config|
30
+ config.error_messages = YAML.safe_load(
31
+ File.read(
32
+ File.join(WaterDrop.gem_root, 'config', 'locales', 'errors.yml')
33
+ )
34
+ ).fetch('en').fetch('validations').fetch('variant')
35
+ end
36
+
37
+ required(:default) { |val| BOOLEANS.include?(val) }
38
+ required(:max_wait_timeout) { |val| val.is_a?(Numeric) && val >= 0 }
39
+
40
+ # Checks if all keys are symbols
41
+ virtual do |config, errors|
42
+ next true unless errors.empty?
43
+
44
+ errors = []
45
+
46
+ config
47
+ .fetch(:topic_config)
48
+ .keys
49
+ .reject { |key| key.is_a?(Symbol) }
50
+ .each { |key| errors << [[:kafka, key], :kafka_key_must_be_a_symbol] }
51
+
52
+ errors
53
+ end
54
+
55
+ # Checks if we have any keys that are not allowed
56
+ virtual do |config, errors|
57
+ next true unless errors.empty?
58
+
59
+ errors = []
60
+
61
+ config
62
+ .fetch(:topic_config)
63
+ .keys
64
+ .reject { |key| TOPIC_CONFIG_KEYS.include?(key) }
65
+ .each { |key| errors << [[:kafka, key], :kafka_key_not_per_topic] }
66
+
67
+ errors
68
+ end
69
+
70
+ # Ensure, that acks is not changed when in transactional mode
71
+ # acks needs to be set to 'all' and should not be changed when working with transactional
72
+ # producer as it causes librdkafka to crash
73
+ virtual do |config, errors|
74
+ next true unless errors.empty?
75
+ # Relevant only for the transactional producer
76
+ next true unless config.fetch(:transactional)
77
+
78
+ errors = []
79
+
80
+ config
81
+ .fetch(:topic_config)
82
+ .keys
83
+ .select { |key| key.to_s.include?('acks') }
84
+ .each { |key| errors << [[:kafka, key], :kafka_key_acks_not_changeable] }
85
+
86
+ errors
87
+ end
88
+
89
+ # Prevent from creating variants altering acks when idempotent
90
+ virtual do |config, errors|
91
+ next true unless errors.empty?
92
+ # Relevant only for the transactional producer
93
+ next true unless config.fetch(:idempotent)
94
+
95
+ errors = []
96
+
97
+ config
98
+ .fetch(:topic_config)
99
+ .keys
100
+ .select { |key| key.to_s.include?('acks') }
101
+ .each { |key| errors << [[:kafka, key], :kafka_key_acks_not_changeable] }
102
+
103
+ errors
104
+ end
105
+ end
106
+ end
107
+ end
@@ -9,6 +9,9 @@ module WaterDrop
9
9
  # Raised when configuration doesn't match with validation contract
10
10
  ConfigurationInvalidError = Class.new(BaseError)
11
11
 
12
+ # Raised when variant alteration is not valid
13
+ VariantInvalidError = Class.new(BaseError)
14
+
12
15
  # Raised when we want to use a producer that was not configured
13
16
  ProducerNotConfiguredError = Class.new(BaseError)
14
17
 
@@ -22,12 +25,18 @@ module WaterDrop
22
25
  # Raised when there was an attempt to use a closed producer
23
26
  ProducerClosedError = Class.new(BaseError)
24
27
 
28
+ # Raised if you attempt to close the producer from within a transaction. This is not allowed.
29
+ ProducerTransactionalCloseAttemptError = Class.new(BaseError)
30
+
25
31
  # Raised when we want to send a message that is invalid (impossible topic, etc)
26
32
  MessageInvalidError = Class.new(BaseError)
27
33
 
28
34
  # Raised when we want to commit transactional offset and the input is invalid
29
35
  TransactionalOffsetInvalidError = Class.new(BaseError)
30
36
 
37
+ # Raised when transaction attempt happens on a non-transactional producer
38
+ ProducerNotTransactionalError = Class.new(BaseError)
39
+
31
40
  # Raised when we've got an unexpected status. This should never happen. If it does, please
32
41
  # contact us as it is an error.
33
42
  StatusInvalidError = Class.new(BaseError)
@@ -44,6 +53,9 @@ module WaterDrop
44
53
  # graceful transaction aborting
45
54
  AbortTransaction = Class.new(BaseError)
46
55
 
56
+ # Do not use `break`, `return` or `throw` inside of the transaction blocks
57
+ EarlyTransactionExitNotAllowedError = Class.new(BaseError)
58
+
47
59
  # Raised when during messages producing something bad happened inline
48
60
  class ProduceManyError < ProduceError
49
61
  attr_reader :dispatched
@@ -57,4 +69,8 @@ module WaterDrop
57
69
  end
58
70
  end
59
71
  end
72
+
73
+ # Alias so we can have a nicer API to abort transactions
74
+ # This makes referencing easier
75
+ AbortTransaction = Errors::AbortTransaction
60
76
  end
@@ -44,6 +44,17 @@ module WaterDrop
44
44
  else
45
45
  instrument_error(delivery_report)
46
46
  end
47
+ # This runs from the rdkafka thread, thus we want to safe-guard it and prevent absolute
48
+ # crashes even if the instrumentation code fails. If it would bubble-up, it could crash
49
+ # the rdkafka background thread
50
+ rescue StandardError => e
51
+ @monitor.instrument(
52
+ 'error.occurred',
53
+ caller: self,
54
+ error: e,
55
+ producer_id: @producer_id,
56
+ type: 'callbacks.delivery.error'
57
+ )
47
58
  end
48
59
 
49
60
  private
@@ -32,6 +32,17 @@ module WaterDrop
32
32
  producer_id: @producer_id,
33
33
  type: 'librdkafka.error'
34
34
  )
35
+ # This runs from the rdkafka thread, thus we want to safe-guard it and prevent absolute
36
+ # crashes even if the instrumentation code fails. If it would bubble-up, it could crash
37
+ # the rdkafka background thread
38
+ rescue StandardError => e
39
+ @monitor.instrument(
40
+ 'error.occurred',
41
+ caller: self,
42
+ error: e,
43
+ producer_id: @producer_id,
44
+ type: 'callbacks.error.error'
45
+ )
35
46
  end
36
47
  end
37
48
  end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WaterDrop
4
+ module Instrumentation
5
+ module Callbacks
6
+ # Callback that is triggered when oauth token needs to be refreshed.
7
+ class OauthbearerTokenRefresh
8
+ # @param bearer [Rdkafka::Producer] given rdkafka instance. It is needed as
9
+ # we need to have a reference to call `#oauthbearer_set_token` or
10
+ # `#oauthbearer_set_token_failure` upon the event.
11
+ # @param monitor [WaterDrop::Instrumentation::Monitor] monitor we are using
12
+ def initialize(bearer, monitor)
13
+ @bearer = bearer
14
+ @monitor = monitor
15
+ end
16
+
17
+ # Upon receiving of this event, user is required to invoke either `#oauthbearer_set_token`
18
+ # or `#oauthbearer_set_token_failure` on the `event[:bearer]` depending whether token
19
+ # obtaining was successful or not.
20
+ #
21
+ # Please refer to WaterDrop and Karafka documentation or `Rdkafka::Helpers::OAuth`
22
+ # documentation directly for exact parameters of those methods.
23
+ #
24
+ # @param _rd_config [Rdkafka::Config]
25
+ # @param bearer_name [String] name of the bearer for which we refresh
26
+ def call(_rd_config, bearer_name)
27
+ return unless @bearer.name == bearer_name
28
+
29
+ @monitor.instrument(
30
+ 'oauthbearer.token_refresh',
31
+ bearer: @bearer,
32
+ caller: self
33
+ )
34
+ # This runs from the rdkafka thread, thus we want to safe-guard it and prevent absolute
35
+ # crashes even if the instrumentation code fails. If it would bubble-up, it could crash
36
+ # the rdkafka background thread
37
+ rescue StandardError => e
38
+ @monitor.instrument(
39
+ 'error.occurred',
40
+ caller: self,
41
+ error: e,
42
+ producer_id: @producer_id,
43
+ type: 'callbacks.oauthbearer_token_refresh.error'
44
+ )
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
@@ -34,6 +34,17 @@ module WaterDrop
34
34
  producer_id: @producer_id,
35
35
  statistics: @statistics_decorator.call(statistics)
36
36
  )
37
+ # This runs from the rdkafka thread, thus we want to safe-guard it and prevent absolute
38
+ # crashes even if the instrumentation code fails. If it would bubble-up, it could crash
39
+ # the rdkafka background thread
40
+ rescue StandardError => e
41
+ @monitor.instrument(
42
+ 'error.occurred',
43
+ caller: self,
44
+ error: e,
45
+ producer_id: @producer_id,
46
+ type: 'callbacks.statistics.error'
47
+ )
37
48
  end
38
49
  end
39
50
  end
@@ -26,7 +26,7 @@ module WaterDrop
26
26
  def on_message_produced_async(event)
27
27
  message = event[:message]
28
28
 
29
- info(event, "Async producing of a message to '#{message[:topic]}' topic")
29
+ info(event, "Message to '#{message[:topic]}' topic was delegated to a dispatch queue")
30
30
 
31
31
  return unless log_messages?
32
32
 
@@ -49,7 +49,10 @@ module WaterDrop
49
49
  messages = event[:messages]
50
50
  topics_count = messages.map { |message| "'#{message[:topic]}'" }.uniq.count
51
51
 
52
- info(event, "Async producing of #{messages.size} messages to #{topics_count} topics")
52
+ info(
53
+ event,
54
+ "#{messages.size} messages to #{topics_count} topics were delegated to a dispatch queue"
55
+ )
53
56
 
54
57
  return unless log_messages?
55
58
 
@@ -129,6 +132,11 @@ module WaterDrop
129
132
  info(event, 'Closing producer')
130
133
  end
131
134
 
135
+ # @param event [Dry::Events::Event] event that happened with the details
136
+ def on_producer_reloaded(event)
137
+ info(event, 'Producer successfully reloaded')
138
+ end
139
+
132
140
  # @param event [Dry::Events::Event] event that happened with the error details
133
141
  def on_error_occurred(event)
134
142
  error = event[:error]
@@ -188,7 +196,7 @@ module WaterDrop
188
196
  # @param log_message [String] message we want to publish
189
197
  def info(event, log_message)
190
198
  if event.payload.key?(:time)
191
- @logger.info("[#{event[:producer_id]}] #{log_message} took #{event[:time]} ms")
199
+ @logger.info("[#{event[:producer_id]}] #{log_message} took #{event[:time].round(2)} ms")
192
200
  else
193
201
  @logger.info("[#{event[:producer_id]}] #{log_message}")
194
202
  end
@@ -10,6 +10,7 @@ module WaterDrop
10
10
  producer.connected
11
11
  producer.closing
12
12
  producer.closed
13
+ producer.reloaded
13
14
 
14
15
  message.produced_async
15
16
  message.produced_sync
@@ -21,6 +22,8 @@ module WaterDrop
21
22
  messages.produced_sync
22
23
  messages.buffered
23
24
 
25
+ oauthbearer.token_refresh
26
+
24
27
  transaction.started
25
28
  transaction.committed
26
29
  transaction.aborted
@@ -36,7 +36,7 @@ module WaterDrop
36
36
  setting :rd_kafka_metrics, default: [
37
37
  # Client metrics
38
38
  RdKafkaMetric.new(:count, :root, 'calls', 'tx_d'),
39
- RdKafkaMetric.new(:histogram, :root, 'queue.size', 'msg_cnt_d'),
39
+ RdKafkaMetric.new(:histogram, :root, 'queue.size', 'msg_cnt'),
40
40
 
41
41
  # Broker metrics
42
42
  RdKafkaMetric.new(:count, :brokers, 'deliver.attempts', 'txretries_d'),
@@ -199,6 +199,15 @@ module WaterDrop
199
199
  tags: default_tags + ["broker:#{broker_statistics['nodename']}"]
200
200
  )
201
201
  end
202
+ when :topics
203
+ statistics.fetch('topics').each_value do |topic_statistics|
204
+ public_send(
205
+ metric.type,
206
+ metric.name,
207
+ topic_statistics.dig(*metric.key_location),
208
+ tags: default_tags + ["topic:#{topic_statistics['topic']}"]
209
+ )
210
+ end
202
211
  else
203
212
  raise ArgumentError, metric.scope
204
213
  end
@@ -12,9 +12,6 @@ module WaterDrop
12
12
  def buffer(message)
13
13
  ensure_active!
14
14
 
15
- message = middleware.run(message)
16
- validate_message!(message)
17
-
18
15
  @monitor.instrument(
19
16
  'message.buffered',
20
17
  producer_id: id,
@@ -32,9 +29,6 @@ module WaterDrop
32
29
  def buffer_many(messages)
33
30
  ensure_active!
34
31
 
35
- messages = middleware.run_many(messages)
36
- messages.each { |message| validate_message!(message) }
37
-
38
32
  @monitor.instrument(
39
33
  'messages.buffered',
40
34
  producer_id: id,
@@ -52,8 +52,8 @@ module WaterDrop
52
52
  # @return [Array<Rdkafka::Producer::DeliveryReport>] delivery reports
53
53
  #
54
54
  # @raise [Rdkafka::RdkafkaError] When adding the messages to rdkafka's queue failed
55
- # @raise [Rdkafka::Producer::WaitTimeoutError] When the timeout has been reached and the
56
- # some handles are still pending
55
+ # @raise [Rdkafka::Producer::WaitTimeoutError] When the timeout has been reached and some
56
+ # handles are still pending
57
57
  # @raise [Errors::MessageInvalidError] When any of the provided messages details are invalid
58
58
  # and the message could not be sent to Kafka
59
59
  def produce_many_sync(messages)
@@ -61,17 +61,37 @@ module WaterDrop
61
61
  messages.each { |message| validate_message!(message) }
62
62
 
63
63
  dispatched = []
64
+ inline_error = nil
64
65
 
65
66
  @monitor.instrument('messages.produced_sync', producer_id: id, messages: messages) do
66
- with_transaction_if_transactional do
67
- messages.each do |message|
68
- dispatched << produce(message)
67
+ # While most of the librdkafka errors are async and not inline, there are some like
68
+ # buffer overflow that can leak in during the `#produce` itself. When this happens, we
69
+ # still (since it's a sync mode) need to wait on deliveries of things that were
70
+ # successfully delegated to librdkafka. To do this, we catch the inline error and we
71
+ # wait on messages that were in the buffer to reach final state. Then if no error, we
72
+ # check each for error and if none all good. If there was an inline error, we re-raise
73
+ # it with the handlers in final states.
74
+ #
75
+ # Such flow ensures, that we never end up with handlers not being in the final states
76
+ # for the sync flow
77
+ begin
78
+ with_transaction_if_transactional do
79
+ messages.each do |message|
80
+ dispatched << produce(message)
81
+ end
69
82
  end
83
+ rescue *SUPPORTED_FLOW_ERRORS => e
84
+ inline_error = e
70
85
  end
71
86
 
72
- dispatched.map! do |handler|
73
- wait(handler)
74
- end
87
+ # This will ensure, that we have all verdicts before raising the failure, so we pass
88
+ # all delivery handles having a final verdict
89
+ dispatched.each { |handler| wait(handler, raise_response_error: false) }
90
+
91
+ raise(inline_error) if inline_error
92
+
93
+ # This will raise an error on the first error that have happened
94
+ dispatched.each { |handler| wait(handler) }
75
95
 
76
96
  dispatched
77
97
  end
@@ -82,7 +102,11 @@ module WaterDrop
82
102
  'error.occurred',
83
103
  producer_id: id,
84
104
  messages: messages,
85
- dispatched: dispatched,
105
+ # If it is a transactional producer nothing was successfully dispatched on error, thus
106
+ # we never return any dispatched handlers. While those messages might have reached
107
+ # Kafka, in transactional mode they will not be visible to consumers with correct
108
+ # isolation level.
109
+ dispatched: transactional? ? EMPTY_ARRAY : dispatched,
86
110
  error: re_raised,
87
111
  type: 'messages.produce_many_sync'
88
112
  )