karafka 2.0.0.beta4 → 2.0.0.rc2

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 (59) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/.github/workflows/ci.yml +18 -1
  4. data/CHANGELOG.md +30 -0
  5. data/CONTRIBUTING.md +0 -5
  6. data/Gemfile.lock +12 -42
  7. data/README.md +2 -12
  8. data/bin/benchmarks +2 -2
  9. data/bin/integrations +10 -3
  10. data/bin/{stress → stress_many} +1 -1
  11. data/bin/stress_one +13 -0
  12. data/config/errors.yml +48 -5
  13. data/docker-compose.yml +27 -18
  14. data/karafka.gemspec +2 -4
  15. data/lib/karafka/active_job/job_options_contract.rb +8 -2
  16. data/lib/karafka/active_job/routing/extensions.rb +1 -1
  17. data/lib/karafka/app.rb +2 -1
  18. data/lib/karafka/base_consumer.rb +24 -19
  19. data/lib/karafka/cli/install.rb +15 -2
  20. data/lib/karafka/cli/server.rb +4 -2
  21. data/lib/karafka/connection/client.rb +40 -17
  22. data/lib/karafka/connection/listener.rb +37 -11
  23. data/lib/karafka/connection/rebalance_manager.rb +20 -19
  24. data/lib/karafka/contracts/base.rb +2 -8
  25. data/lib/karafka/contracts/config.rb +71 -38
  26. data/lib/karafka/contracts/consumer_group.rb +25 -18
  27. data/lib/karafka/contracts/consumer_group_topic.rb +30 -16
  28. data/lib/karafka/contracts/server_cli_options.rb +18 -7
  29. data/lib/karafka/errors.rb +3 -0
  30. data/lib/karafka/helpers/colorize.rb +20 -0
  31. data/lib/karafka/pro/active_job/consumer.rb +1 -8
  32. data/lib/karafka/pro/active_job/job_options_contract.rb +10 -6
  33. data/lib/karafka/pro/base_consumer.rb +27 -21
  34. data/lib/karafka/pro/loader.rb +13 -6
  35. data/lib/karafka/pro/processing/coordinator.rb +63 -0
  36. data/lib/karafka/pro/processing/jobs_builder.rb +3 -2
  37. data/lib/karafka/pro/processing/partitioner.rb +41 -0
  38. data/lib/karafka/pro/processing/scheduler.rb +56 -0
  39. data/lib/karafka/pro/routing/extensions.rb +6 -0
  40. data/lib/karafka/processing/coordinator.rb +88 -0
  41. data/lib/karafka/processing/coordinators_buffer.rb +54 -0
  42. data/lib/karafka/processing/executor.rb +7 -17
  43. data/lib/karafka/processing/executors_buffer.rb +46 -15
  44. data/lib/karafka/processing/jobs/consume.rb +4 -2
  45. data/lib/karafka/processing/jobs_builder.rb +3 -2
  46. data/lib/karafka/processing/partitioner.rb +22 -0
  47. data/lib/karafka/processing/result.rb +0 -5
  48. data/lib/karafka/processing/scheduler.rb +22 -0
  49. data/lib/karafka/routing/consumer_group.rb +1 -1
  50. data/lib/karafka/routing/topic.rb +9 -0
  51. data/lib/karafka/setup/config.rb +26 -12
  52. data/lib/karafka/templates/example_consumer.rb.erb +2 -2
  53. data/lib/karafka/version.rb +1 -1
  54. data/lib/karafka.rb +0 -2
  55. data.tar.gz.sig +0 -0
  56. metadata +15 -36
  57. metadata.gz.sig +0 -0
  58. data/lib/karafka/pro/scheduler.rb +0 -54
  59. data/lib/karafka/scheduler.rb +0 -20
@@ -5,6 +5,8 @@ module Karafka
5
5
  class Cli < Thor
6
6
  # Server Karafka Cli action
7
7
  class Server < Base
8
+ include Helpers::Colorize
9
+
8
10
  desc 'Start the Karafka server (short-cut alias: "s")'
9
11
  option aliases: 's'
10
12
  option :consumer_groups, type: :array, default: nil, aliases: :g
@@ -31,11 +33,11 @@ module Karafka
31
33
 
32
34
  if Karafka.pro?
33
35
  Karafka.logger.info(
34
- "\033[0;32mThank you for investing in the Karafka Pro subscription!\033[0m\n"
36
+ green('Thank you for investing in the Karafka Pro subscription!')
35
37
  )
36
38
  else
37
39
  Karafka.logger.info(
38
- "\033[0;31mYou like Karafka? Please consider getting a Pro subscription!\033[0m\n"
40
+ red('You like Karafka? Please consider getting a Pro version!')
39
41
  )
40
42
  end
41
43
  end
@@ -36,6 +36,12 @@ module Karafka
36
36
  # Marks if we need to offset. If we did not store offsets, we should not commit the offset
37
37
  # position as it will crash rdkafka
38
38
  @offsetting = false
39
+ # We need to keep track of what we have paused for resuming
40
+ # In case we loose partition, we still need to resume it, otherwise it won't be fetched
41
+ # again if we get reassigned to it later on. We need to keep them as after revocation we
42
+ # no longer may be able to fetch them from Kafka. We could build them but it is easier
43
+ # to just keep them here and use if needed when cannot be obtained
44
+ @paused_tpls = Hash.new { |h, k| h[k] = {} }
39
45
  end
40
46
 
41
47
  # Fetches messages within boundaries defined by the settings (time, size, topics, etc).
@@ -45,12 +51,13 @@ module Karafka
45
51
  # @note This method should not be executed from many threads at the same time
46
52
  def batch_poll
47
53
  time_poll = TimeTrackers::Poll.new(@subscription_group.max_wait_time)
48
- time_poll.start
49
54
 
50
55
  @buffer.clear
51
56
  @rebalance_manager.clear
52
57
 
53
58
  loop do
59
+ time_poll.start
60
+
54
61
  # Don't fetch more messages if we do not have any time left
55
62
  break if time_poll.exceeded?
56
63
  # Don't fetch more messages if we've fetched max as we've wanted
@@ -69,7 +76,11 @@ module Karafka
69
76
  # If partition revocation happens, we need to remove messages from revoked partitions
70
77
  # as well as ensure we do not have duplicated due to the offset reset for partitions
71
78
  # that we got assigned
72
- remove_revoked_and_duplicated_messages if @rebalance_manager.revoked_partitions?
79
+ # We also do early break, so the information about rebalance is used as soon as possible
80
+ if @rebalance_manager.changed?
81
+ remove_revoked_and_duplicated_messages
82
+ break
83
+ end
73
84
 
74
85
  # Finally once we've (potentially) removed revoked, etc, if no messages were returned
75
86
  # we can break.
@@ -144,10 +155,14 @@ module Karafka
144
155
 
145
156
  internal_commit_offsets(async: false)
146
157
 
158
+ # Here we do not use our cached tpls because we should not try to pause something we do
159
+ # not own anymore.
147
160
  tpl = topic_partition_list(topic, partition)
148
161
 
149
162
  return unless tpl
150
163
 
164
+ @paused_tpls[topic][partition] = tpl
165
+
151
166
  @kafka.pause(tpl)
152
167
 
153
168
  @kafka.seek(pause_msg)
@@ -169,9 +184,13 @@ module Karafka
169
184
  # We can skip performance penalty since resuming should not happen too often
170
185
  internal_commit_offsets(async: false)
171
186
 
172
- tpl = topic_partition_list(topic, partition)
187
+ # If we were not able, let's try to reuse the one we have (if we have)
188
+ tpl = topic_partition_list(topic, partition) || @paused_tpls[topic][partition]
173
189
 
174
190
  return unless tpl
191
+ # If we did not have it, it means we never paused this partition, thus no resume should
192
+ # happen in the first place
193
+ return unless @paused_tpls[topic].delete(partition)
175
194
 
176
195
  @kafka.resume(tpl)
177
196
  ensure
@@ -214,6 +233,7 @@ module Karafka
214
233
  @mutex.synchronize do
215
234
  @closed = false
216
235
  @offsetting = false
236
+ @paused_tpls.clear
217
237
  @kafka = build_consumer
218
238
  end
219
239
  end
@@ -296,37 +316,40 @@ module Karafka
296
316
 
297
317
  time_poll.start
298
318
 
299
- @kafka.poll(time_poll.remaining)
319
+ @kafka.poll(timeout)
300
320
  rescue ::Rdkafka::RdkafkaError => e
301
- raise if time_poll.attempts > MAX_POLL_RETRIES
302
- raise unless time_poll.retryable?
303
-
321
+ # We return nil, so we do not restart until running the whole loop
322
+ # This allows us to run revocation jobs and other things and we will pick up new work
323
+ # next time after dispatching all the things that are needed
324
+ #
325
+ # If we would retry here, the client reset would become transparent and we would not have
326
+ # a chance to take any actions
304
327
  case e.code
305
328
  when :max_poll_exceeded # -147
306
329
  reset
330
+ return nil
307
331
  when :transport # -195
308
332
  reset
333
+ return nil
309
334
  when :rebalance_in_progress # -27
310
335
  reset
336
+ return nil
311
337
  when :not_coordinator # 16
312
338
  reset
339
+ return nil
313
340
  when :network_exception # 13
314
341
  reset
342
+ return nil
315
343
  end
316
344
 
317
- time_poll.checkpoint
318
-
345
+ raise if time_poll.attempts > MAX_POLL_RETRIES
319
346
  raise unless time_poll.retryable?
320
347
 
348
+ time_poll.checkpoint
321
349
  time_poll.backoff
322
350
 
323
- # We return nil, so we do not restart until running the whole loop
324
- # This allows us to run revocation jobs and other things and we will pick up new work
325
- # next time after dispatching all the things that are needed
326
- #
327
- # If we would retry here, the client reset would become transparent and we would not have
328
- # a chance to take any actions
329
- nil
351
+ # On unknown errors we do our best to retry and handle them before raising
352
+ retry
330
353
  end
331
354
 
332
355
  # Builds a new rdkafka consumer instance based on the subscription group configuration
@@ -369,7 +392,7 @@ module Karafka
369
392
  # we are no longer responsible in a given process for processing those messages and they
370
393
  # should have been picked up by a different process.
371
394
  def remove_revoked_and_duplicated_messages
372
- @rebalance_manager.revoked_partitions.each do |topic, partitions|
395
+ @rebalance_manager.lost_partitions.each do |topic, partitions|
373
396
  partitions.each do |partition|
374
397
  @buffer.delete(topic, partition)
375
398
  end
@@ -18,15 +18,18 @@ module Karafka
18
18
  # @param jobs_queue [Karafka::Processing::JobsQueue] queue where we should push work
19
19
  # @return [Karafka::Connection::Listener] listener instance
20
20
  def initialize(subscription_group, jobs_queue)
21
+ proc_config = ::Karafka::App.config.internal.processing
22
+
21
23
  @id = SecureRandom.uuid
22
24
  @subscription_group = subscription_group
23
25
  @jobs_queue = jobs_queue
24
- @jobs_builder = ::Karafka::App.config.internal.jobs_builder
25
- @pauses_manager = PausesManager.new
26
+ @coordinators = Processing::CoordinatorsBuffer.new
26
27
  @client = Client.new(@subscription_group)
27
28
  @executors = Processing::ExecutorsBuffer.new(@client, subscription_group)
29
+ @jobs_builder = proc_config.jobs_builder
30
+ @partitioner = proc_config.partitioner_class.new(subscription_group)
28
31
  # We reference scheduler here as it is much faster than fetching this each time
29
- @scheduler = ::Karafka::App.config.internal.scheduler
32
+ @scheduler = proc_config.scheduler
30
33
  # We keep one buffer for messages to preserve memory and not allocate extra objects
31
34
  # We can do this that way because we always first schedule jobs using messages before we
32
35
  # fetch another batch.
@@ -86,6 +89,9 @@ module Karafka
86
89
  build_and_schedule_revoke_lost_partitions_jobs
87
90
 
88
91
  # We wait only on jobs from our subscription group. Other groups are independent.
92
+ # This will block on revoked jobs until they are finished. Those are not meant to last
93
+ # long and should not have any bigger impact on the system. Doing this in a blocking way
94
+ # simplifies the overall design and prevents from race conditions
89
95
  wait
90
96
 
91
97
  build_and_schedule_consumption_jobs
@@ -136,7 +142,7 @@ module Karafka
136
142
 
137
143
  # Resumes processing of partitions that were paused due to an error.
138
144
  def resume_paused_partitions
139
- @pauses_manager.resume do |topic, partition|
145
+ @coordinators.resume do |topic, partition|
140
146
  @client.resume(topic, partition)
141
147
  end
142
148
  end
@@ -152,9 +158,21 @@ module Karafka
152
158
 
153
159
  revoked_partitions.each do |topic, partitions|
154
160
  partitions.each do |partition|
155
- pause_tracker = @pauses_manager.fetch(topic, partition)
156
- executor = @executors.fetch(topic, partition, pause_tracker)
157
- jobs << @jobs_builder.revoked(executor)
161
+ @coordinators.revoke(topic, partition)
162
+
163
+ # There may be a case where we have lost partition of which data we have never
164
+ # processed (if it was assigned and revoked really fast), thus we may not have it
165
+ # here. In cases like this, we do not run a revocation job
166
+ @executors.find_all(topic, partition).each do |executor|
167
+ jobs << @jobs_builder.revoked(executor)
168
+ end
169
+
170
+ # We need to remove all the executors of a given topic partition that we have lost, so
171
+ # next time we pick up it's work, new executors kick in. This may be needed especially
172
+ # for LRJ where we could end up with a race condition
173
+ # This revocation needs to happen after the jobs are scheduled, otherwise they would
174
+ # be scheduled with new executors instead of old
175
+ @executors.revoke(topic, partition)
158
176
  end
159
177
  end
160
178
 
@@ -191,11 +209,19 @@ module Karafka
191
209
  jobs = []
192
210
 
193
211
  @messages_buffer.each do |topic, partition, messages|
194
- pause_tracker = @pauses_manager.fetch(topic, partition)
212
+ coordinator = @coordinators.find_or_create(topic, partition)
213
+
214
+ # Start work coordination for this topic partition
215
+ coordinator.start(messages)
195
216
 
196
- executor = @executors.fetch(topic, partition, pause_tracker)
217
+ @partitioner.call(topic, messages) do |group_id, partition_messages|
218
+ # Count the job we're going to create here
219
+ coordinator.increment
197
220
 
198
- jobs << @jobs_builder.consume(executor, messages)
221
+ executor = @executors.find_or_create(topic, partition, group_id)
222
+
223
+ jobs << @jobs_builder.consume(executor, partition_messages, coordinator)
224
+ end
199
225
  end
200
226
 
201
227
  @scheduler.schedule_consumption(@jobs_queue, jobs)
@@ -231,7 +257,7 @@ module Karafka
231
257
  @jobs_queue.wait(@subscription_group.id)
232
258
  @jobs_queue.clear(@subscription_group.id)
233
259
  @client.reset
234
- @pauses_manager = PausesManager.new
260
+ @coordinators.reset
235
261
  @executors = Processing::ExecutorsBuffer.new(@client, @subscription_group)
236
262
  end
237
263
  end
@@ -18,13 +18,15 @@ module Karafka
18
18
  # Empty array for internal usage not to create new objects
19
19
  EMPTY_ARRAY = [].freeze
20
20
 
21
+ attr_reader :assigned_partitions, :revoked_partitions
22
+
21
23
  private_constant :EMPTY_ARRAY
22
24
 
23
25
  # @return [RebalanceManager]
24
26
  def initialize
25
27
  @assigned_partitions = {}
26
28
  @revoked_partitions = {}
27
- @lost_partitions = {}
29
+ @changed = false
28
30
  end
29
31
 
30
32
  # Resets the rebalance manager state
@@ -33,26 +35,12 @@ module Karafka
33
35
  def clear
34
36
  @assigned_partitions.clear
35
37
  @revoked_partitions.clear
36
- @lost_partitions.clear
37
- end
38
-
39
- # @return [Hash<String, Array<Integer>>] hash where the keys are the names of topics for
40
- # which we've lost partitions and array with ids of the partitions as the value
41
- # @note We do not consider as lost topics and partitions that got revoked and assigned
42
- def revoked_partitions
43
- return @revoked_partitions if @revoked_partitions.empty?
44
- return @lost_partitions unless @lost_partitions.empty?
45
-
46
- @revoked_partitions.each do |topic, partitions|
47
- @lost_partitions[topic] = partitions - @assigned_partitions.fetch(topic, EMPTY_ARRAY)
48
- end
49
-
50
- @lost_partitions
38
+ @changed = false
51
39
  end
52
40
 
53
- # @return [Boolean] true if any partitions were revoked
54
- def revoked_partitions?
55
- !revoked_partitions.empty?
41
+ # @return [Boolean] indicates a state change in the partitions assignment
42
+ def changed?
43
+ @changed
56
44
  end
57
45
 
58
46
  # Callback that kicks in inside of rdkafka, when new partitions are assigned.
@@ -62,6 +50,7 @@ module Karafka
62
50
  # @param partitions [Rdkafka::Consumer::TopicPartitionList]
63
51
  def on_partitions_assigned(_, partitions)
64
52
  @assigned_partitions = partitions.to_h.transform_values { |part| part.map(&:partition) }
53
+ @changed = true
65
54
  end
66
55
 
67
56
  # Callback that kicks in inside of rdkafka, when partitions are revoked.
@@ -71,6 +60,18 @@ module Karafka
71
60
  # @param partitions [Rdkafka::Consumer::TopicPartitionList]
72
61
  def on_partitions_revoked(_, partitions)
73
62
  @revoked_partitions = partitions.to_h.transform_values { |part| part.map(&:partition) }
63
+ @changed = true
64
+ end
65
+
66
+ # We consider as lost only partitions that were taken away and not re-assigned back to us
67
+ def lost_partitions
68
+ lost_partitions = {}
69
+
70
+ revoked_partitions.each do |topic, partitions|
71
+ lost_partitions[topic] = partitions - assigned_partitions.fetch(topic, EMPTY_ARRAY)
72
+ end
73
+
74
+ lost_partitions
74
75
  end
75
76
  end
76
77
  end
@@ -3,20 +3,14 @@
3
3
  module Karafka
4
4
  module Contracts
5
5
  # Base contract for all Karafka contracts
6
- class Base < Dry::Validation::Contract
7
- config.messages.load_paths << File.join(Karafka.gem_root, 'config', 'errors.yml')
8
-
6
+ class Base < ::WaterDrop::Contractable::Contract
9
7
  # @param data [Hash] data for validation
10
8
  # @return [Boolean] true if all good
11
9
  # @raise [Errors::InvalidConfigurationError] invalid configuration error
12
10
  # @note We use contracts only in the config validation context, so no need to add support
13
11
  # for multiple error classes. It will be added when it will be needed.
14
12
  def validate!(data)
15
- result = call(data)
16
-
17
- return true if result.success?
18
-
19
- raise Errors::InvalidConfigurationError, result.errors.to_h
13
+ super(data, Errors::InvalidConfigurationError)
20
14
  end
21
15
  end
22
16
  end
@@ -9,57 +9,90 @@ module Karafka
9
9
  # validated per each route (topic + consumer_group) because they can be overwritten,
10
10
  # so we validate all of that once all the routes are defined and ready.
11
11
  class Config < Base
12
- params do
13
- # License validity happens in the licenser. Here we do only the simple consistency checks
14
- required(:license).schema do
15
- required(:token) { bool? | str? }
16
- required(:entity) { str? }
17
- required(:expires_on) { date? }
12
+ configure do |config|
13
+ config.error_messages = YAML.safe_load(
14
+ File.read(
15
+ File.join(Karafka.gem_root, 'config', 'errors.yml')
16
+ )
17
+ ).fetch('en').fetch('validations').fetch('config')
18
+ end
19
+
20
+ # License validity happens in the licenser. Here we do only the simple consistency checks
21
+ nested(:license) do
22
+ required(:token) { |val| [true, false].include?(val) || val.is_a?(String) }
23
+ required(:entity) { |val| val.is_a?(String) }
24
+ required(:expires_on) { |val| val.is_a?(Date) }
25
+ end
26
+
27
+ required(:client_id) { |val| val.is_a?(String) && Contracts::TOPIC_REGEXP.match?(val) }
28
+ required(:concurrency) { |val| val.is_a?(Integer) && val.positive? }
29
+ required(:consumer_mapper) { |val| !val.nil? }
30
+ required(:consumer_persistence) { |val| [true, false].include?(val) }
31
+ required(:pause_timeout) { |val| val.is_a?(Integer) && val.positive? }
32
+ required(:pause_max_timeout) { |val| val.is_a?(Integer) && val.positive? }
33
+ required(:pause_with_exponential_backoff) { |val| [true, false].include?(val) }
34
+ required(:shutdown_timeout) { |val| val.is_a?(Integer) && val.positive? }
35
+ required(:max_wait_time) { |val| val.is_a?(Integer) && val.positive? }
36
+ required(:kafka) { |val| val.is_a?(Hash) && !val.empty? }
37
+
38
+ # We validate internals just to be sure, that they are present and working
39
+ nested(:internal) do
40
+ required(:status) { |val| !val.nil? }
41
+ required(:process) { |val| !val.nil? }
42
+
43
+ nested(:routing) do
44
+ required(:builder) { |val| !val.nil? }
45
+ required(:subscription_groups_builder) { |val| !val.nil? }
46
+ end
47
+
48
+ nested(:processing) do
49
+ required(:jobs_builder) { |val| !val.nil? }
50
+ required(:scheduler) { |val| !val.nil? }
51
+ required(:coordinator_class) { |val| !val.nil? }
52
+ required(:partitioner_class) { |val| !val.nil? }
18
53
  end
19
54
 
20
- required(:client_id).filled(:str?, format?: Karafka::Contracts::TOPIC_REGEXP)
21
- required(:concurrency) { int? & gt?(0) }
22
- required(:consumer_mapper).filled
23
- required(:consumer_persistence).filled(:bool?)
24
- required(:pause_timeout) { int? & gt?(0) }
25
- required(:pause_max_timeout) { int? & gt?(0) }
26
- required(:pause_with_exponential_backoff).filled(:bool?)
27
- required(:shutdown_timeout) { int? & gt?(0) }
28
- required(:max_wait_time) { int? & gt?(0) }
29
- required(:kafka).filled(:hash)
30
-
31
- # We validate internals just to be sure, that they are present and working
32
- required(:internal).schema do
33
- required(:routing_builder)
34
- required(:subscription_groups_builder)
35
- required(:jobs_builder)
36
- required(:status)
37
- required(:process)
38
- required(:scheduler)
55
+ nested(:active_job) do
56
+ required(:dispatcher) { |val| !val.nil? }
57
+ required(:job_options_contract) { |val| !val.nil? }
58
+ required(:consumer_class) { |val| !val.nil? }
39
59
  end
40
60
  end
41
61
 
42
- # rdkafka requires all the keys to be strings, so we ensure that
43
- rule(:kafka) do
44
- next unless value.is_a?(Hash)
62
+ virtual do |data, errors|
63
+ next unless errors.empty?
64
+
65
+ detected_errors = []
45
66
 
46
- value.each_key do |key|
67
+ data.fetch(:kafka).each_key do |key|
47
68
  next if key.is_a?(Symbol)
48
69
 
49
- key(:"kafka.#{key}").failure(:kafka_key_must_be_a_symbol)
70
+ detected_errors << [[:kafka, key], :key_must_be_a_symbol]
50
71
  end
72
+
73
+ detected_errors
51
74
  end
52
75
 
53
- rule(:pause_timeout, :pause_max_timeout) do
54
- if values[:pause_timeout].to_i > values[:pause_max_timeout].to_i
55
- key(:pause_timeout).failure(:max_timeout_vs_pause_max_timeout)
56
- end
76
+ virtual do |data, errors|
77
+ next unless errors.empty?
78
+
79
+ pause_timeout = data.fetch(:pause_timeout)
80
+ pause_max_timeout = data.fetch(:pause_max_timeout)
81
+
82
+ next if pause_timeout <= pause_max_timeout
83
+
84
+ [[%i[pause_timeout], :max_timeout_vs_pause_max_timeout]]
57
85
  end
58
86
 
59
- rule(:shutdown_timeout, :max_wait_time) do
60
- if values[:max_wait_time].to_i >= values[:shutdown_timeout].to_i
61
- key(:shutdown_timeout).failure(:shutdown_timeout_vs_max_wait_time)
62
- end
87
+ virtual do |data, errors|
88
+ next unless errors.empty?
89
+
90
+ shutdown_timeout = data.fetch(:shutdown_timeout)
91
+ max_wait_time = data.fetch(:max_wait_time)
92
+
93
+ next if max_wait_time < shutdown_timeout
94
+
95
+ [[%i[shutdown_timeout], :shutdown_timeout_vs_max_wait_time]]
63
96
  end
64
97
  end
65
98
  end
@@ -4,32 +4,39 @@ module Karafka
4
4
  module Contracts
5
5
  # Contract for single full route (consumer group + topics) validation.
6
6
  class ConsumerGroup < Base
7
- # Internal contract for sub-validating topics schema
8
- TOPIC_CONTRACT = ConsumerGroupTopic.new.freeze
7
+ configure do |config|
8
+ config.error_messages = YAML.safe_load(
9
+ File.read(
10
+ File.join(Karafka.gem_root, 'config', 'errors.yml')
11
+ )
12
+ ).fetch('en').fetch('validations').fetch('consumer_group')
13
+ end
9
14
 
10
- private_constant :TOPIC_CONTRACT
15
+ required(:id) { |id| id.is_a?(String) && Contracts::TOPIC_REGEXP.match?(id) }
16
+ required(:topics) { |topics| topics.is_a?(Array) && !topics.empty? }
11
17
 
12
- params do
13
- required(:id).filled(:str?, format?: Karafka::Contracts::TOPIC_REGEXP)
14
- required(:topics).value(:array, :filled?)
15
- end
18
+ virtual do |data, errors|
19
+ next unless errors.empty?
16
20
 
17
- rule(:topics) do
18
- if value.is_a?(Array)
19
- names = value.map { |topic| topic[:name] }
21
+ names = data.fetch(:topics).map { |topic| topic[:name] }
20
22
 
21
- key.failure(:topics_names_not_unique) if names.size != names.uniq.size
22
- end
23
+ next if names.size == names.uniq.size
24
+
25
+ [[%i[topics], :names_not_unique]]
23
26
  end
24
27
 
25
- rule(:topics) do
26
- if value.is_a?(Array)
27
- value.each_with_index do |topic, index|
28
- TOPIC_CONTRACT.call(topic).errors.each do |error|
29
- key([:topics, index, error.path[0]]).failure(error.text)
30
- end
28
+ virtual do |data, errors|
29
+ next unless errors.empty?
30
+
31
+ fetched_errors = []
32
+
33
+ data.fetch(:topics).each do |topic|
34
+ ConsumerGroupTopic.new.call(topic).errors.each do |key, value|
35
+ fetched_errors << [[topic, key].flatten, value]
31
36
  end
32
37
  end
38
+
39
+ fetched_errors
33
40
  end
34
41
  end
35
42
  end
@@ -4,24 +4,38 @@ module Karafka
4
4
  module Contracts
5
5
  # Consumer group topic validation rules.
6
6
  class ConsumerGroupTopic < Base
7
- params do
8
- required(:consumer).filled
9
- required(:deserializer).filled
10
- required(:id).filled(:str?, format?: Karafka::Contracts::TOPIC_REGEXP)
11
- required(:kafka).filled
12
- required(:max_messages) { int? & gteq?(1) }
13
- required(:initial_offset).filled(included_in?: %w[earliest latest])
14
- required(:max_wait_time).filled { int? & gteq?(10) }
15
- required(:manual_offset_management).filled(:bool?)
16
- required(:name).filled(:str?, format?: Karafka::Contracts::TOPIC_REGEXP)
7
+ configure do |config|
8
+ config.error_messages = YAML.safe_load(
9
+ File.read(
10
+ File.join(Karafka.gem_root, 'config', 'errors.yml')
11
+ )
12
+ ).fetch('en').fetch('validations').fetch('consumer_group_topic')
17
13
  end
18
14
 
19
- rule(:kafka) do
20
- # This will trigger rdkafka validations that we catch and re-map the info and use dry
21
- # compatible format
22
- Rdkafka::Config.new(value).send(:native_config)
23
- rescue Rdkafka::Config::ConfigError => e
24
- key(:kafka).failure(e.message)
15
+ required(:consumer) { |consumer_group| !consumer_group.nil? }
16
+ required(:deserializer) { |deserializer| !deserializer.nil? }
17
+ required(:id) { |id| id.is_a?(String) && Contracts::TOPIC_REGEXP.match?(id) }
18
+ required(:kafka) { |kafka| kafka.is_a?(Hash) && !kafka.empty? }
19
+ required(:max_messages) { |mm| mm.is_a?(Integer) && mm >= 1 }
20
+ required(:initial_offset) { |io| %w[earliest latest].include?(io) }
21
+ required(:max_wait_time) { |mwt| mwt.is_a?(Integer) && mwt >= 10 }
22
+ required(:manual_offset_management) { |mmm| [true, false].include?(mmm) }
23
+ required(:name) { |name| name.is_a?(String) && Contracts::TOPIC_REGEXP.match?(name) }
24
+
25
+ virtual do |data, errors|
26
+ next unless errors.empty?
27
+
28
+ value = data.fetch(:kafka)
29
+
30
+ begin
31
+ # This will trigger rdkafka validations that we catch and re-map the info and use dry
32
+ # compatible format
33
+ Rdkafka::Config.new(value).send(:native_config)
34
+
35
+ nil
36
+ rescue Rdkafka::Config::ConfigError => e
37
+ [[%w[kafka], e.message]]
38
+ end
25
39
  end
26
40
  end
27
41
  end
@@ -4,17 +4,28 @@ module Karafka
4
4
  module Contracts
5
5
  # Contract for validating correctness of the server cli command options.
6
6
  class ServerCliOptions < Base
7
- params do
8
- optional(:consumer_groups).value(:array, :filled?)
7
+ configure do |config|
8
+ config.error_messages = YAML.safe_load(
9
+ File.read(
10
+ File.join(Karafka.gem_root, 'config', 'errors.yml')
11
+ )
12
+ ).fetch('en').fetch('validations').fetch('server_cli_options')
9
13
  end
10
14
 
11
- rule(:consumer_groups) do
15
+ optional(:consumer_groups) { |cg| cg.is_a?(Array) && !cg.empty? }
16
+
17
+ virtual do |data, errors|
18
+ next unless errors.empty?
19
+ next unless data.key?(:consumer_groups)
20
+
21
+ value = data.fetch(:consumer_groups)
22
+
12
23
  # If there were no consumer_groups declared in the server cli, it means that we will
13
24
  # run all of them and no need to validate them here at all
14
- if !value.nil? &&
15
- !(value - Karafka::App.config.internal.routing_builder.map(&:name)).empty?
16
- key(:consumer_groups).failure(:consumer_groups_inclusion)
17
- end
25
+ next if value.nil?
26
+ next if (value - Karafka::App.config.internal.routing.builder.map(&:name)).empty?
27
+
28
+ [[%i[consumer_groups], :consumer_groups_inclusion]]
18
29
  end
19
30
  end
20
31
  end
@@ -47,5 +47,8 @@ module Karafka
47
47
  # Used to instrument this error into the error notifications
48
48
  # We do not raise it so we won't crash deployed systems
49
49
  ExpiredLicenseTokenError = Class.new(BaseError)
50
+
51
+ # This should never happen. Please open an issue if it does.
52
+ InvalidCoordinatorState = Class.new(BaseError)
50
53
  end
51
54
  end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Karafka
4
+ module Helpers
5
+ # Simple wrapper for adding colors to strings
6
+ module Colorize
7
+ # @param string [String] string we want to have in green
8
+ # @return [String] green string
9
+ def green(string)
10
+ "\033[0;32m#{string}\033[0m"
11
+ end
12
+
13
+ # @param string [String] string we want to have in red
14
+ # @return [String] red string
15
+ def red(string)
16
+ "\033[0;31m#{string}\033[0m"
17
+ end
18
+ end
19
+ end
20
+ end