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
@@ -4,10 +4,16 @@ module WaterDrop
4
4
  class Producer
5
5
  # Transactions related producer functionalities
6
6
  module Transactions
7
+ # We should never reload producer if it was fenced, otherwise we could end up with some sort
8
+ # of weird race-conditions
9
+ NON_RELOADABLE_ERRORS = %i[
10
+ fenced
11
+ ].freeze
12
+
7
13
  # Contract to validate that input for transactional offset storage is correct
8
14
  CONTRACT = Contracts::TransactionalOffset.new
9
15
 
10
- private_constant :CONTRACT
16
+ private_constant :CONTRACT, :NON_RELOADABLE_ERRORS
11
17
 
12
18
  # Creates a transaction.
13
19
  #
@@ -29,7 +35,7 @@ module WaterDrop
29
35
  # with a transaction. One transaction per single dispatch and for `produce_many` it will be
30
36
  # a single transaction wrapping all messages dispatches (not one per message).
31
37
  #
32
- # @return Block result
38
+ # @return Block result or `nil` in case of early break/return
33
39
  #
34
40
  # @example Simple transaction
35
41
  # producer.transaction do
@@ -39,7 +45,7 @@ module WaterDrop
39
45
  # @example Aborted transaction - messages producer won't be visible by consumers
40
46
  # producer.transaction do
41
47
  # producer.produce_sync(topic: 'topic', payload: 'data')
42
- # throw(:abort)
48
+ # raise WaterDrop::AbortTransaction
43
49
  # end
44
50
  #
45
51
  # @example Use block result last handler to wait on all messages ack
@@ -49,26 +55,46 @@ module WaterDrop
49
55
  #
50
56
  # handler.wait
51
57
  def transaction
58
+ unless transactional?
59
+ raise(
60
+ Errors::ProducerNotTransactionalError,
61
+ "#{id} is not transactional"
62
+ )
63
+ end
64
+
52
65
  # This will safely allow us to support one operation transactions so a transactional
53
66
  # producer can work without the transactional block if needed
54
67
  return yield if @transaction_mutex.owned?
55
68
 
56
69
  @transaction_mutex.synchronize do
70
+ ensure_active!
71
+
57
72
  transactional_instrument(:finished) do
58
73
  with_transactional_error_handling(:begin) do
59
74
  transactional_instrument(:started) { client.begin_transaction }
60
75
  end
61
76
 
62
77
  result = nil
63
- commit = false
78
+ finished = false
64
79
 
65
- catch(:abort) do
80
+ begin
66
81
  result = yield
67
- commit = true
82
+ finished = true
83
+ rescue Exception => e
84
+ raise(e)
85
+ ensure
86
+ if !e && !finished
87
+ raise(
88
+ Errors::EarlyTransactionExitNotAllowedError,
89
+ <<~ERROR_MSG.tr("\n", ' ')
90
+ Using `return`, `break` or `throw` to exit a transaction block is not allowed.
91
+ If the `throw` came from `Timeout.timeout(duration)`, pass an exception class as
92
+ a second argument so it doesn't use `throw` to abort its block.
93
+ ERROR_MSG
94
+ )
95
+ end
68
96
  end
69
97
 
70
- commit || raise(WaterDrop::Errors::AbortTransaction)
71
-
72
98
  with_transactional_error_handling(:commit) do
73
99
  transactional_instrument(:committed) { client.commit_transaction }
74
100
  end
@@ -76,19 +102,37 @@ module WaterDrop
76
102
  result
77
103
  # We need to handle any interrupt including critical in order not to have the transaction
78
104
  # running. This will also handle things like `IRB::Abort`
79
- #
80
- # rubocop:disable Lint/RescueException
81
105
  rescue Exception => e
82
- # rubocop:enable Lint/RescueException
83
- with_transactional_error_handling(:abort) do
84
- transactional_instrument(:aborted) { client.abort_transaction }
106
+ # This code is a bit tricky. We have an error and when it happens we try to rollback
107
+ # the transaction. However we may end up in a state where transaction aborting itself
108
+ # produces error. In such case we also want to handle it as fatal and reload client.
109
+ # This is why we catch this here
110
+ begin
111
+ with_transactional_error_handling(:abort) do
112
+ transactional_instrument(:aborted) do
113
+ client.abort_transaction
114
+ end
115
+ end
116
+ rescue StandardError => e
117
+ # If something from rdkafka leaks here, it means there was a non-retryable error that
118
+ # bubbled up. In such cases if we should, we do reload the underling client
119
+ transactional_reload_client_if_needed(e)
120
+
121
+ raise
85
122
  end
86
123
 
124
+ transactional_reload_client_if_needed(e)
125
+
87
126
  raise unless e.is_a?(WaterDrop::Errors::AbortTransaction)
88
127
  end
89
128
  end
90
129
  end
91
130
 
131
+ # @return [Boolean] true if we are in an active transaction
132
+ def transaction?
133
+ @transaction_mutex.owned?
134
+ end
135
+
92
136
  # @return [Boolean] Is this producer a transactional one
93
137
  def transactional?
94
138
  return @transactional if instance_variable_defined?(:'@transactional')
@@ -132,8 +176,7 @@ module WaterDrop
132
176
  client.send_offsets_to_transaction(
133
177
  consumer,
134
178
  tpl,
135
- # This setting is at the moment in seconds and we require ms
136
- @config.max_wait_timeout * 1_000
179
+ current_variant.max_wait_timeout
137
180
  )
138
181
  end
139
182
  end
@@ -193,11 +236,16 @@ module WaterDrop
193
236
  attempt: attempt
194
237
  )
195
238
 
196
- raise if e.fatal?
239
+ if e.fatal?
240
+ # Reload the client on fatal errors if requested
241
+ transactional_reload_client_if_needed(e)
242
+
243
+ raise
244
+ end
197
245
 
198
246
  if do_retry
199
247
  # Backoff more and more before retries
200
- sleep(config.wait_backoff_on_transaction_command * attempt)
248
+ sleep((config.wait_backoff_on_transaction_command / 1_000.0) * attempt)
201
249
 
202
250
  retry
203
251
  end
@@ -208,12 +256,43 @@ module WaterDrop
208
256
  with_transactional_error_handling(:abort, allow_abortable: false) do
209
257
  transactional_instrument(:aborted) { client.abort_transaction }
210
258
  end
211
-
212
- raise
213
259
  end
214
260
 
215
261
  raise
216
262
  end
263
+
264
+ # Reloads the underlying client instance if needed and allowed
265
+ #
266
+ # This should be used only in transactions as only then we can get fatal transactional
267
+ # errors and we can safely reload the client.
268
+ #
269
+ # @param error [Exception] any error that was raised
270
+ #
271
+ # @note We only reload on rdkafka errors that are a cause on messages dispatches.
272
+ # Because we reload on any errors where cause is `Rdkafka::RdkafkaError` (minus exclusions)
273
+ # this in theory can cause reload if it was something else that raised those in transactions,
274
+ # for example Karafka. This is a trade-off. Since any error anyhow will cause a rollback,
275
+ # putting aside performance implication of closing and reconnecting, this should not be an
276
+ # issue.
277
+ def transactional_reload_client_if_needed(error)
278
+ rd_error = error.is_a?(Rdkafka::RdkafkaError) ? error : error.cause
279
+
280
+ return unless rd_error.is_a?(Rdkafka::RdkafkaError)
281
+ return unless config.reload_on_transaction_fatal_error
282
+ return if NON_RELOADABLE_ERRORS.include?(rd_error.code)
283
+
284
+ @operating_mutex.synchronize do
285
+ @monitor.instrument(
286
+ 'producer.reloaded',
287
+ producer_id: id
288
+ ) do
289
+ @client.flush(current_variant.max_wait_timeout)
290
+ purge
291
+ @client.close
292
+ @client = Builder.new.call(self, @config)
293
+ end
294
+ end
295
+ end
217
296
  end
218
297
  end
219
298
  end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WaterDrop
4
+ class Producer
5
+ # Object that acts as a proxy allowing for alteration of certain low-level per-topic
6
+ # configuration and some other settings that users may find useful to alter, without having
7
+ # to create new producers with their underlying librdkafka instances.
8
+ #
9
+ # Since each librdkafka instance creates at least one TCP connection per broker, creating
10
+ # separate objects just to alter thing like `acks` may not be efficient and may lead to
11
+ # extensive usage of TCP connections, especially in bigger clusters.
12
+ #
13
+ # This variant object allows for "wrapping" of the producer with alteration of those settings
14
+ # in such a way, that two or more alterations can co-exist and share the same producer,
15
+ # effectively sharing the librdkafka client.
16
+ #
17
+ # Since this is an enhanced `SimpleDelegator` all `WaterDrop::Producer` APIs are preserved and
18
+ # a variant alteration can be used as a regular producer. The only important thing is to
19
+ # remember to only close it once.
20
+ #
21
+ # @note Not all settings are alterable. We only allow to alter things that are safe to be
22
+ # altered as they have no impact on the producer. If there is a setting you consider
23
+ # important and want to make it alterable, please open a GH issue for evaluation.
24
+ #
25
+ # @note Please be aware, that variant changes also affect buffers. If you overwrite the
26
+ # `max_wait_timeout`, since buffers are shared (as they exist on producer level), flushing
27
+ # may be impacted.
28
+ #
29
+ # @note `topic_config` is validated when created for the first time during message production.
30
+ # This means, that configuration error may be raised only during dispatch. There is no
31
+ # way out of this, since we need `librdkafka` instance to create the references.
32
+ class Variant < SimpleDelegator
33
+ # Empty hash we use as defaults for topic config.
34
+ # When rdkafka-ruby detects empty hash, it will use the librdkafka defaults
35
+ EMPTY_HASH = {}.freeze
36
+
37
+ private_constant :EMPTY_HASH
38
+
39
+ attr_reader :max_wait_timeout, :topic_config, :producer
40
+
41
+ # @param producer [WaterDrop::Producer] producer for which we want to have a variant
42
+ # @param max_wait_timeout [Integer, nil] alteration to max wait timeout or nil to use
43
+ # default
44
+ # @param topic_config [Hash] extra topic configuration that can be altered.
45
+ # @param default [Boolean] is this a default variant or an altered one
46
+ # @see https://karafka.io/docs/Librdkafka-Configuration/#topic-configuration-properties
47
+ def initialize(
48
+ producer,
49
+ max_wait_timeout: producer.config.max_wait_timeout,
50
+ topic_config: EMPTY_HASH,
51
+ default: false
52
+ )
53
+ @producer = producer
54
+ @max_wait_timeout = max_wait_timeout
55
+ @topic_config = topic_config
56
+ @default = default
57
+ super(producer)
58
+
59
+ Contracts::Variant.new.validate!(to_h, Errors::VariantInvalidError)
60
+ end
61
+
62
+ # @return [Boolean] is this a default variant for this producer
63
+ def default?
64
+ @default
65
+ end
66
+
67
+ # We need to wrap any methods from our API that could use a variant alteration with the
68
+ # per thread variant injection. Since method_missing can be slow and problematic, it is just
69
+ # easier to use our public API components methods to ensure the variant is being injected.
70
+ [
71
+ Async,
72
+ Buffer,
73
+ Sync,
74
+ Transactions
75
+ ].each do |scope|
76
+ scope.instance_methods(false).each do |method_name|
77
+ class_eval <<-RUBY, __FILE__, __LINE__ + 1
78
+ def #{method_name}(*args, &block)
79
+ ref = Fiber.current.waterdrop_clients ||= {}
80
+ ref[@producer.id] = self
81
+
82
+ @producer.#{method_name}(*args, &block)
83
+ ensure
84
+ ref[@producer.id] = nil
85
+ end
86
+ RUBY
87
+ end
88
+ end
89
+
90
+ private
91
+
92
+ # @return [Hash] hash representation for contract validation to ensure basic sanity of the
93
+ # settings.
94
+ def to_h
95
+ {
96
+ default: default?,
97
+ max_wait_timeout: max_wait_timeout,
98
+ topic_config: topic_config,
99
+ # We pass this to validation, to make sure no-one alters the `acks` value when operating
100
+ # in the transactional mode as it causes librdkafka to crash ruby
101
+ # @see https://github.com/confluentinc/librdkafka/issues/4710
102
+ transactional: @producer.transactional?,
103
+ # We pass this for a similar reason as above
104
+ idempotent: @producer.idempotent?
105
+ }
106
+ end
107
+ end
108
+ end
109
+ end
@@ -9,6 +9,10 @@ module WaterDrop
9
9
  include Buffer
10
10
  include Transactions
11
11
  include ::Karafka::Core::Helpers::Time
12
+ include ::Karafka::Core::Taggable
13
+
14
+ # Local storage for given thread waterdrop client references for variants
15
+ ::Fiber.send(:attr_accessor, :waterdrop_clients)
12
16
 
13
17
  # Which of the inline flow errors do we want to intercept and re-bind
14
18
  SUPPORTED_FLOW_ERRORS = [
@@ -16,12 +20,15 @@ module WaterDrop
16
20
  Rdkafka::Producer::DeliveryHandle::WaitTimeoutError
17
21
  ].freeze
18
22
 
19
- # Empty has to save on memory allocations
23
+ # Empty hash to save on memory allocations
20
24
  EMPTY_HASH = {}.freeze
21
25
 
22
- private_constant :SUPPORTED_FLOW_ERRORS, :EMPTY_HASH
26
+ # Empty array to save on memory allocations
27
+ EMPTY_ARRAY = [].freeze
28
+
29
+ private_constant :SUPPORTED_FLOW_ERRORS, :EMPTY_HASH, :EMPTY_ARRAY
23
30
 
24
- def_delegators :config, :middleware
31
+ def_delegators :config
25
32
 
26
33
  # @return [String] uuid of the current producer
27
34
  attr_reader :id
@@ -65,6 +72,7 @@ module WaterDrop
65
72
  @id = @config.id
66
73
  @monitor = @config.monitor
67
74
  @contract = Contracts::Message.new(max_payload_size: @config.max_payload_size)
75
+ @default_variant = Variant.new(self, default: true)
68
76
  @status.configured!
69
77
  end
70
78
 
@@ -78,6 +86,7 @@ module WaterDrop
78
86
 
79
87
  # Don't allow to obtain a client reference for a producer that was not configured
80
88
  raise Errors::ProducerNotConfiguredError, id if @status.initial?
89
+ raise Errors::ProducerClosedError, id if @status.closed?
81
90
 
82
91
  @connecting_mutex.synchronize do
83
92
  return @client if @client && @pid == Process.pid
@@ -104,18 +113,6 @@ module WaterDrop
104
113
  @pid = Process.pid
105
114
  @client = Builder.new.call(self, @config)
106
115
 
107
- # Register statistics runner for this particular type of callbacks
108
- ::Karafka::Core::Instrumentation.statistics_callbacks.add(
109
- @id,
110
- Instrumentation::Callbacks::Statistics.new(@id, @client.name, @config.monitor)
111
- )
112
-
113
- # Register error tracking callback
114
- ::Karafka::Core::Instrumentation.error_callbacks.add(
115
- @id,
116
- Instrumentation::Callbacks::Error.new(@id, @client.name, @config.monitor)
117
- )
118
-
119
116
  @status.connected!
120
117
  @monitor.instrument('producer.connected', producer_id: id)
121
118
  end
@@ -145,73 +142,115 @@ module WaterDrop
145
142
  @messages = []
146
143
  end
147
144
 
148
- @client.purge
145
+ # We should not purge if there is no client initialized
146
+ # It may not be initialized if we created a new producer that never connected to kafka,
147
+ # we used buffer and purged. In cases like this client won't exist
148
+ @connecting_mutex.synchronize do
149
+ @client&.purge
150
+ end
149
151
  end
150
152
  end
151
153
 
154
+ # Builds the variant alteration and returns it.
155
+ #
156
+ # @param args [Object] anything `Producer::Variant` initializer accepts
157
+ # @return [WaterDrop::Producer::Variant] variant proxy to use with alterations
158
+ def with(**args)
159
+ ensure_active!
160
+
161
+ Variant.new(self, **args)
162
+ end
163
+
164
+ alias variant with
165
+
166
+ # @return [Boolean] true if current producer is idempotent
167
+ def idempotent?
168
+ # Every transactional producer is idempotent by default always
169
+ return true if transactional?
170
+ return @idempotent if instance_variable_defined?(:'@idempotent')
171
+
172
+ @idempotent = config.kafka.to_h.key?(:'enable.idempotence')
173
+ end
174
+
175
+ # Returns and caches the middleware object that may be used
176
+ # @return [WaterDrop::Producer::Middleware]
177
+ def middleware
178
+ @middleware ||= config.middleware
179
+ end
180
+
152
181
  # Flushes the buffers in a sync way and closes the producer
153
182
  # @param force [Boolean] should we force closing even with outstanding messages after the
154
183
  # max wait timeout
155
184
  def close(force: false)
156
- @operating_mutex.synchronize do
157
- return unless @status.active?
158
-
159
- @monitor.instrument(
160
- 'producer.closed',
161
- producer_id: id
162
- ) do
163
- @status.closing!
164
- @monitor.instrument('producer.closing', producer_id: id)
165
-
166
- # No need for auto-gc if everything got closed by us
167
- # This should be used only in case a producer was not closed properly and forgotten
168
- ObjectSpace.undefine_finalizer(id)
169
-
170
- # We save this thread id because we need to bypass the activity verification on the
171
- # producer for final flush of buffers.
172
- @closing_thread_id = Thread.current.object_id
173
-
174
- # Wait until all the outgoing operations are done. Only when no one is using the
175
- # underlying client running operations we can close
176
- sleep(0.001) until @operations_in_progress.value.zero?
177
-
178
- # Flush has its own buffer mutex but even if it is blocked, flushing can still happen
179
- # as we close the client after the flushing (even if blocked by the mutex)
180
- flush(true)
181
-
182
- # We should not close the client in several threads the same time
183
- # It is safe to run it several times but not exactly the same moment
184
- # We also mark it as closed only if it was connected, if not, it would trigger a new
185
- # connection that anyhow would be immediately closed
186
- if @client
187
- # Why do we trigger it early instead of just having `#close` do it?
188
- # The linger.ms time will be ignored for the duration of the call,
189
- # queued messages will be sent to the broker as soon as possible.
190
- begin
191
- # `max_wait_timeout` is in seconds at the moment
192
- @client.flush(@config.max_wait_timeout * 1_000) unless @client.closed?
193
- # We can safely ignore timeouts here because any left outstanding requests
194
- # will anyhow force wait on close if not forced.
195
- # If forced, we will purge the queue and just close
196
- rescue ::Rdkafka::RdkafkaError, Rdkafka::AbstractHandle::WaitTimeoutError
197
- nil
198
- ensure
199
- # Purge fully the local queue in case of a forceful shutdown just to be sure, that
200
- # there are no dangling messages. In case flush was successful, there should be
201
- # none but we do it just in case it timed out
202
- purge if force
185
+ # If we already own the transactional mutex, it means we are inside of a transaction and
186
+ # it should not we allowed to close the producer in such a case.
187
+ if @transaction_mutex.locked? && @transaction_mutex.owned?
188
+ raise Errors::ProducerTransactionalCloseAttemptError, id
189
+ end
190
+
191
+ # The transactional mutex here can be used even when no transactions are in use
192
+ # It prevents us from closing a mutex during transactions and is irrelevant in other cases
193
+ @transaction_mutex.synchronize do
194
+ @operating_mutex.synchronize do
195
+ return unless @status.active?
196
+
197
+ @monitor.instrument(
198
+ 'producer.closed',
199
+ producer_id: id
200
+ ) do
201
+ @status.closing!
202
+ @monitor.instrument('producer.closing', producer_id: id)
203
+
204
+ # No need for auto-gc if everything got closed by us
205
+ # This should be used only in case a producer was not closed properly and forgotten
206
+ ObjectSpace.undefine_finalizer(id)
207
+
208
+ # We save this thread id because we need to bypass the activity verification on the
209
+ # producer for final flush of buffers.
210
+ @closing_thread_id = Thread.current.object_id
211
+
212
+ # Wait until all the outgoing operations are done. Only when no one is using the
213
+ # underlying client running operations we can close
214
+ sleep(0.001) until @operations_in_progress.value.zero?
215
+
216
+ # Flush has its own buffer mutex but even if it is blocked, flushing can still happen
217
+ # as we close the client after the flushing (even if blocked by the mutex)
218
+ flush(true)
219
+
220
+ # We should not close the client in several threads the same time
221
+ # It is safe to run it several times but not exactly the same moment
222
+ # We also mark it as closed only if it was connected, if not, it would trigger a new
223
+ # connection that anyhow would be immediately closed
224
+ if @client
225
+ # Why do we trigger it early instead of just having `#close` do it?
226
+ # The linger.ms time will be ignored for the duration of the call,
227
+ # queued messages will be sent to the broker as soon as possible.
228
+ begin
229
+ @client.flush(current_variant.max_wait_timeout) unless @client.closed?
230
+ # We can safely ignore timeouts here because any left outstanding requests
231
+ # will anyhow force wait on close if not forced.
232
+ # If forced, we will purge the queue and just close
233
+ rescue ::Rdkafka::RdkafkaError, Rdkafka::AbstractHandle::WaitTimeoutError
234
+ nil
235
+ ensure
236
+ # Purge fully the local queue in case of a forceful shutdown just to be sure, that
237
+ # there are no dangling messages. In case flush was successful, there should be
238
+ # none but we do it just in case it timed out
239
+ purge if force
240
+ end
241
+
242
+ @client.close
243
+
244
+ @client = nil
203
245
  end
204
246
 
205
- @client.close
247
+ # Remove callbacks runners that were registered
248
+ ::Karafka::Core::Instrumentation.statistics_callbacks.delete(@id)
249
+ ::Karafka::Core::Instrumentation.error_callbacks.delete(@id)
250
+ ::Karafka::Core::Instrumentation.oauthbearer_token_refresh_callbacks.delete(@id)
206
251
 
207
- @client = nil
252
+ @status.closed!
208
253
  end
209
-
210
- # Remove callbacks runners that were registered
211
- ::Karafka::Core::Instrumentation.statistics_callbacks.delete(@id)
212
- ::Karafka::Core::Instrumentation.error_callbacks.delete(@id)
213
-
214
- @status.closed!
215
254
  end
216
255
  end
217
256
  end
@@ -248,13 +287,23 @@ module WaterDrop
248
287
  # Waits on a given handler
249
288
  #
250
289
  # @param handler [Rdkafka::Producer::DeliveryHandle]
251
- def wait(handler)
290
+ # @param raise_response_error [Boolean] should we raise the response error after we receive the
291
+ # final result and it is an error.
292
+ def wait(handler, raise_response_error: true)
252
293
  handler.wait(
253
- max_wait_timeout: @config.max_wait_timeout,
254
- wait_timeout: @config.wait_timeout
294
+ # rdkafka max_wait_timeout is in seconds and we use ms
295
+ max_wait_timeout: current_variant.max_wait_timeout / 1_000.0,
296
+ raise_response_error: raise_response_error
255
297
  )
256
298
  end
257
299
 
300
+ # @return [Producer::Variant] the variant config. Either custom if built using `#with` or
301
+ # a default one.
302
+ def current_variant
303
+ Fiber.current.waterdrop_clients ||= {}
304
+ Fiber.current.waterdrop_clients[id] || @default_variant
305
+ end
306
+
258
307
  # Runs the client produce method with a given message
259
308
  #
260
309
  # @param message [Hash] message we want to send
@@ -270,9 +319,17 @@ module WaterDrop
270
319
  ensure_active!
271
320
  end
272
321
 
273
- # In case someone defines topic as a symbol, we need to convert it into a string as
274
- # librdkafka does not accept symbols
275
- message = message.merge(topic: message[:topic].to_s) if message[:topic].is_a?(Symbol)
322
+ # We basically only duplicate the message hash only if it is needed.
323
+ # It is needed when user is using a custom settings variant or when symbol is provided as
324
+ # the topic name. We should never mutate user input message as it may be a hash that the
325
+ # user is using for some other operations
326
+ if message[:topic].is_a?(Symbol) || !current_variant.default?
327
+ message = message.dup
328
+ # In case someone defines topic as a symbol, we need to convert it into a string as
329
+ # librdkafka does not accept symbols
330
+ message[:topic] = message[:topic].to_s
331
+ message[:topic_config] = current_variant.topic_config
332
+ end
276
333
 
277
334
  if transactional?
278
335
  transaction { client.produce(**message) }
@@ -286,9 +343,9 @@ module WaterDrop
286
343
  # If we're running for longer than the timeout, we need to re-raise the queue full.
287
344
  # This will prevent from situation where cluster is down forever and we just retry and retry
288
345
  # in an infinite loop, effectively hanging the processing
289
- raise unless monotonic_now - produce_time < @config.wait_timeout_on_queue_full * 1_000
346
+ raise unless monotonic_now - produce_time < @config.wait_timeout_on_queue_full
290
347
 
291
- label = caller_locations(2, 1)[0].label.split(' ').last
348
+ label = caller_locations(2, 1)[0].label.split(' ').last.split('#').last
292
349
 
293
350
  # We use this syntax here because we want to preserve the original `#cause` when we
294
351
  # instrument the error and there is no way to manually assign `#cause` value. We want to keep
@@ -297,22 +354,28 @@ module WaterDrop
297
354
  begin
298
355
  raise Errors::ProduceError, e.inspect
299
356
  rescue Errors::ProduceError => e
300
- # We want to instrument on this event even when we restart it.
301
- # The reason is simple: instrumentation and visibility.
302
- # We can recover from this, but despite that we should be able to instrument this.
303
- # If this type of event happens too often, it may indicate that the buffer settings are not
304
- # well configured.
305
- @monitor.instrument(
306
- 'error.occurred',
307
- producer_id: id,
308
- message: message,
309
- error: e,
310
- type: "message.#{label}"
311
- )
357
+ # Users can configure this because in pipe-like flows with high throughput, queue full with
358
+ # retry may be used as a throttling system that will backoff and wait.
359
+ # In such scenarios this error notification can be removed and until queue full is
360
+ # retryable, it will not be raised as an error.
361
+ if @config.instrument_on_wait_queue_full
362
+ # We want to instrument on this event even when we restart it.
363
+ # The reason is simple: instrumentation and visibility.
364
+ # We can recover from this, but despite that we should be able to instrument this.
365
+ # If this type of event happens too often, it may indicate that the buffer settings are
366
+ # not well configured.
367
+ @monitor.instrument(
368
+ 'error.occurred',
369
+ producer_id: id,
370
+ message: message,
371
+ error: e,
372
+ type: "message.#{label}"
373
+ )
374
+ end
312
375
 
313
376
  # We do not poll the producer because polling happens in a background thread
314
377
  # It also should not be a frequent case (queue full), hence it's ok to just throttle.
315
- sleep @config.wait_backoff_on_queue_full
378
+ sleep @config.wait_backoff_on_queue_full / 1_000.0
316
379
  end
317
380
 
318
381
  @operations_in_progress.decrement
@@ -3,5 +3,5 @@
3
3
  # WaterDrop library
4
4
  module WaterDrop
5
5
  # Current WaterDrop version
6
- VERSION = '2.6.14'
6
+ VERSION = '2.8.2'
7
7
  end
data/lib/waterdrop.rb CHANGED
@@ -3,6 +3,7 @@
3
3
  # External components
4
4
  # delegate should be removed because we don't need it, we just add it because of ruby-kafka
5
5
  %w[
6
+ delegate
6
7
  forwardable
7
8
  json
8
9
  zeitwerk