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.
- checksums.yaml +4 -4
- checksums.yaml.gz.sig +0 -0
- data/.github/workflows/ci.yml +18 -1
- data/CHANGELOG.md +30 -0
- data/CONTRIBUTING.md +0 -5
- data/Gemfile.lock +12 -42
- data/README.md +2 -12
- data/bin/benchmarks +2 -2
- data/bin/integrations +10 -3
- data/bin/{stress → stress_many} +1 -1
- data/bin/stress_one +13 -0
- data/config/errors.yml +48 -5
- data/docker-compose.yml +27 -18
- data/karafka.gemspec +2 -4
- data/lib/karafka/active_job/job_options_contract.rb +8 -2
- data/lib/karafka/active_job/routing/extensions.rb +1 -1
- data/lib/karafka/app.rb +2 -1
- data/lib/karafka/base_consumer.rb +24 -19
- data/lib/karafka/cli/install.rb +15 -2
- data/lib/karafka/cli/server.rb +4 -2
- data/lib/karafka/connection/client.rb +40 -17
- data/lib/karafka/connection/listener.rb +37 -11
- data/lib/karafka/connection/rebalance_manager.rb +20 -19
- data/lib/karafka/contracts/base.rb +2 -8
- data/lib/karafka/contracts/config.rb +71 -38
- data/lib/karafka/contracts/consumer_group.rb +25 -18
- data/lib/karafka/contracts/consumer_group_topic.rb +30 -16
- data/lib/karafka/contracts/server_cli_options.rb +18 -7
- data/lib/karafka/errors.rb +3 -0
- data/lib/karafka/helpers/colorize.rb +20 -0
- data/lib/karafka/pro/active_job/consumer.rb +1 -8
- data/lib/karafka/pro/active_job/job_options_contract.rb +10 -6
- data/lib/karafka/pro/base_consumer.rb +27 -21
- data/lib/karafka/pro/loader.rb +13 -6
- data/lib/karafka/pro/processing/coordinator.rb +63 -0
- data/lib/karafka/pro/processing/jobs_builder.rb +3 -2
- data/lib/karafka/pro/processing/partitioner.rb +41 -0
- data/lib/karafka/pro/processing/scheduler.rb +56 -0
- data/lib/karafka/pro/routing/extensions.rb +6 -0
- data/lib/karafka/processing/coordinator.rb +88 -0
- data/lib/karafka/processing/coordinators_buffer.rb +54 -0
- data/lib/karafka/processing/executor.rb +7 -17
- data/lib/karafka/processing/executors_buffer.rb +46 -15
- data/lib/karafka/processing/jobs/consume.rb +4 -2
- data/lib/karafka/processing/jobs_builder.rb +3 -2
- data/lib/karafka/processing/partitioner.rb +22 -0
- data/lib/karafka/processing/result.rb +0 -5
- data/lib/karafka/processing/scheduler.rb +22 -0
- data/lib/karafka/routing/consumer_group.rb +1 -1
- data/lib/karafka/routing/topic.rb +9 -0
- data/lib/karafka/setup/config.rb +26 -12
- data/lib/karafka/templates/example_consumer.rb.erb +2 -2
- data/lib/karafka/version.rb +1 -1
- data/lib/karafka.rb +0 -2
- data.tar.gz.sig +0 -0
- metadata +15 -36
- metadata.gz.sig +0 -0
- data/lib/karafka/pro/scheduler.rb +0 -54
- data/lib/karafka/scheduler.rb +0 -20
data/lib/karafka/cli/server.rb
CHANGED
@@ -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
|
-
|
36
|
+
green('Thank you for investing in the Karafka Pro subscription!')
|
35
37
|
)
|
36
38
|
else
|
37
39
|
Karafka.logger.info(
|
38
|
-
|
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
|
-
|
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
|
-
|
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(
|
319
|
+
@kafka.poll(timeout)
|
300
320
|
rescue ::Rdkafka::RdkafkaError => e
|
301
|
-
|
302
|
-
|
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.
|
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
|
-
#
|
324
|
-
|
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.
|
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
|
-
@
|
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 =
|
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
|
-
@
|
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
|
-
|
156
|
-
|
157
|
-
|
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
|
-
|
212
|
+
coordinator = @coordinators.find_or_create(topic, partition)
|
213
|
+
|
214
|
+
# Start work coordination for this topic partition
|
215
|
+
coordinator.start(messages)
|
195
216
|
|
196
|
-
|
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
|
-
|
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
|
-
@
|
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
|
-
@
|
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
|
-
@
|
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]
|
54
|
-
def
|
55
|
-
|
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 <
|
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
|
-
|
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
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
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
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
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
|
-
|
43
|
-
|
44
|
-
|
62
|
+
virtual do |data, errors|
|
63
|
+
next unless errors.empty?
|
64
|
+
|
65
|
+
detected_errors = []
|
45
66
|
|
46
|
-
|
67
|
+
data.fetch(:kafka).each_key do |key|
|
47
68
|
next if key.is_a?(Symbol)
|
48
69
|
|
49
|
-
|
70
|
+
detected_errors << [[:kafka, key], :key_must_be_a_symbol]
|
50
71
|
end
|
72
|
+
|
73
|
+
detected_errors
|
51
74
|
end
|
52
75
|
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
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
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
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
|
-
|
8
|
-
|
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
|
-
|
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
|
-
|
13
|
-
|
14
|
-
required(:topics).value(:array, :filled?)
|
15
|
-
end
|
18
|
+
virtual do |data, errors|
|
19
|
+
next unless errors.empty?
|
16
20
|
|
17
|
-
|
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
|
-
|
22
|
-
|
23
|
+
next if names.size == names.uniq.size
|
24
|
+
|
25
|
+
[[%i[topics], :names_not_unique]]
|
23
26
|
end
|
24
27
|
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
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
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
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
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
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
|
-
|
8
|
-
|
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
|
-
|
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
|
15
|
-
|
16
|
-
|
17
|
-
|
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
|
data/lib/karafka/errors.rb
CHANGED
@@ -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
|