racecar 1.2.0 → 2.0.0.alpha1
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
- data/CHANGELOG.md +25 -15
- data/README.md +37 -63
- data/lib/ensure_hash_compact.rb +10 -0
- data/lib/racecar.rb +3 -5
- data/lib/racecar/cli.rb +2 -7
- data/lib/racecar/config.rb +79 -55
- data/lib/racecar/consumer.rb +32 -11
- data/lib/racecar/consumer_set.rb +177 -0
- data/lib/racecar/ctl.rb +6 -9
- data/lib/racecar/pause.rb +55 -0
- data/lib/racecar/runner.rb +189 -119
- data/lib/racecar/version.rb +1 -1
- data/racecar.gemspec +3 -2
- metadata +26 -9
data/lib/racecar/consumer.rb
CHANGED
@@ -1,11 +1,11 @@
|
|
1
1
|
module Racecar
|
2
2
|
class Consumer
|
3
|
-
Subscription = Struct.new(:topic, :start_from_beginning, :max_bytes_per_partition)
|
3
|
+
Subscription = Struct.new(:topic, :start_from_beginning, :max_bytes_per_partition, :additional_config)
|
4
4
|
|
5
5
|
class << self
|
6
6
|
attr_accessor :max_wait_time
|
7
7
|
attr_accessor :group_id
|
8
|
-
attr_accessor :
|
8
|
+
attr_accessor :producer, :consumer
|
9
9
|
|
10
10
|
def subscriptions
|
11
11
|
@subscriptions ||= []
|
@@ -20,29 +20,50 @@ module Racecar
|
|
20
20
|
# of each partition.
|
21
21
|
# @param max_bytes_per_partition [Integer] the maximum number of bytes to fetch from
|
22
22
|
# each partition at a time.
|
23
|
+
# @param additional_config [Hash] Configuration properties for consumer.
|
24
|
+
# See https://github.com/edenhill/librdkafka/blob/master/CONFIGURATION.md
|
23
25
|
# @return [nil]
|
24
|
-
def subscribes_to(*topics, start_from_beginning: true, max_bytes_per_partition: 1048576)
|
26
|
+
def subscribes_to(*topics, start_from_beginning: true, max_bytes_per_partition: 1048576, additional_config: {})
|
25
27
|
topics.each do |topic|
|
26
|
-
subscriptions << Subscription.new(topic, start_from_beginning, max_bytes_per_partition)
|
28
|
+
subscriptions << Subscription.new(topic, start_from_beginning, max_bytes_per_partition, additional_config)
|
27
29
|
end
|
28
30
|
end
|
29
31
|
end
|
30
32
|
|
31
|
-
def configure(consumer:,
|
32
|
-
@
|
33
|
-
@
|
33
|
+
def configure(producer:, consumer:, instrumenter:)
|
34
|
+
@producer = producer
|
35
|
+
@consumer = consumer
|
36
|
+
@instrumenter = instrumenter
|
34
37
|
end
|
35
38
|
|
36
39
|
def teardown; end
|
37
40
|
|
41
|
+
# Delivers messages that got produced.
|
42
|
+
def deliver!
|
43
|
+
@delivery_handles ||= []
|
44
|
+
@delivery_handles.each(&:wait)
|
45
|
+
@delivery_handles.clear
|
46
|
+
end
|
47
|
+
|
38
48
|
protected
|
39
49
|
|
40
|
-
|
41
|
-
|
50
|
+
# https://github.com/appsignal/rdkafka-ruby#producing-messages
|
51
|
+
def produce(topic:, payload:, key:, headers: nil)
|
52
|
+
@delivery_handles ||= []
|
53
|
+
|
54
|
+
extra_info = {
|
55
|
+
value: payload,
|
56
|
+
key: key,
|
57
|
+
topic: topic,
|
58
|
+
create_time: Time.now,
|
59
|
+
}
|
60
|
+
@instrumenter.instrument("produce_message.racecar", extra_info) do
|
61
|
+
@delivery_handles << @producer.produce(topic: topic, payload: payload, key: key, headers: headers)
|
62
|
+
end
|
42
63
|
end
|
43
64
|
|
44
|
-
def
|
45
|
-
|
65
|
+
def heartbeat
|
66
|
+
warn "DEPRECATION WARNING: Manual heartbeats are not supported and not needed with librdkafka."
|
46
67
|
end
|
47
68
|
end
|
48
69
|
end
|
@@ -0,0 +1,177 @@
|
|
1
|
+
module Racecar
|
2
|
+
class ConsumerSet
|
3
|
+
def initialize(config, logger)
|
4
|
+
@config, @logger = config, logger
|
5
|
+
raise ArgumentError, "Subscriptions must not be empty when subscribing" if @config.subscriptions.empty?
|
6
|
+
|
7
|
+
@consumers = []
|
8
|
+
@consumer_id_iterator = (0...@config.subscriptions.size).cycle
|
9
|
+
|
10
|
+
@last_poll_read_nil_message = false
|
11
|
+
end
|
12
|
+
|
13
|
+
def poll(timeout_ms)
|
14
|
+
maybe_select_next_consumer
|
15
|
+
retried ||= false
|
16
|
+
msg = current.poll(timeout_ms)
|
17
|
+
rescue Rdkafka::RdkafkaError => e
|
18
|
+
@logger.error "Error for topic subscription #{current_subscription}: #{e}"
|
19
|
+
|
20
|
+
case e.code
|
21
|
+
when :max_poll_exceeded
|
22
|
+
reset_current_consumer
|
23
|
+
raise if retried
|
24
|
+
retried = true
|
25
|
+
retry
|
26
|
+
else
|
27
|
+
raise
|
28
|
+
end
|
29
|
+
ensure
|
30
|
+
@last_poll_read_nil_message = true if msg.nil?
|
31
|
+
end
|
32
|
+
|
33
|
+
# XXX: messages are not guaranteed to be from the same partition
|
34
|
+
def batch_poll(timeout_ms)
|
35
|
+
@batch_started_at = Time.now
|
36
|
+
@messages = []
|
37
|
+
while collect_messages_for_batch? do
|
38
|
+
msg = poll(timeout_ms)
|
39
|
+
break if msg.nil?
|
40
|
+
@messages << msg
|
41
|
+
end
|
42
|
+
@messages
|
43
|
+
end
|
44
|
+
|
45
|
+
def store_offset(message)
|
46
|
+
current.store_offset(message)
|
47
|
+
end
|
48
|
+
|
49
|
+
def commit
|
50
|
+
each_subscribed do |consumer|
|
51
|
+
commit_rescue_no_offset(consumer)
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
def close
|
56
|
+
each_subscribed(&:close)
|
57
|
+
end
|
58
|
+
|
59
|
+
def current
|
60
|
+
@consumers[@consumer_id_iterator.peek] ||= begin
|
61
|
+
consumer = Rdkafka::Config.new(rdkafka_config(current_subscription)).consumer
|
62
|
+
consumer.subscribe current_subscription.topic
|
63
|
+
consumer
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
def each_subscribed
|
68
|
+
if block_given?
|
69
|
+
@consumers.each { |c| yield c }
|
70
|
+
else
|
71
|
+
@consumers.each
|
72
|
+
end
|
73
|
+
end
|
74
|
+
|
75
|
+
def pause(topic, partition, offset)
|
76
|
+
consumer, filtered_tpl = find_consumer_by(topic, partition)
|
77
|
+
if !consumer
|
78
|
+
@logger.info "Attempted to pause #{topic}/#{partition}, but we're not subscribed to it"
|
79
|
+
return
|
80
|
+
end
|
81
|
+
|
82
|
+
consumer.pause(filtered_tpl)
|
83
|
+
fake_msg = OpenStruct.new(topic: topic, partition: partition, offset: offset)
|
84
|
+
consumer.seek(fake_msg)
|
85
|
+
end
|
86
|
+
|
87
|
+
def resume(topic, partition)
|
88
|
+
consumer, filtered_tpl = find_consumer_by(topic, partition)
|
89
|
+
if !consumer
|
90
|
+
@logger.info "Attempted to resume #{topic}/#{partition}, but we're not subscribed to it"
|
91
|
+
return
|
92
|
+
end
|
93
|
+
|
94
|
+
consumer.resume(filtered_tpl)
|
95
|
+
end
|
96
|
+
|
97
|
+
alias :each :each_subscribed
|
98
|
+
|
99
|
+
# Subscribe to all topics eagerly, even if there's still messages elsewhere. Usually
|
100
|
+
# that's not needed and Kafka might rebalance if topics are not polled frequently
|
101
|
+
# enough.
|
102
|
+
def subscribe_all
|
103
|
+
@config.subscriptions.size.times do
|
104
|
+
current
|
105
|
+
select_next_consumer
|
106
|
+
end
|
107
|
+
end
|
108
|
+
|
109
|
+
private
|
110
|
+
|
111
|
+
def find_consumer_by(topic, partition)
|
112
|
+
each do |consumer|
|
113
|
+
tpl = consumer.assignment.to_h
|
114
|
+
rdkafka_partition = tpl[topic]&.detect { |part| part.partition == partition }
|
115
|
+
next unless rdkafka_partition
|
116
|
+
filtered_tpl = Rdkafka::Consumer::TopicPartitionList.new({ topic => [rdkafka_partition] })
|
117
|
+
return consumer, filtered_tpl
|
118
|
+
end
|
119
|
+
|
120
|
+
return nil, nil
|
121
|
+
end
|
122
|
+
|
123
|
+
def current_subscription
|
124
|
+
@config.subscriptions[@consumer_id_iterator.peek]
|
125
|
+
end
|
126
|
+
|
127
|
+
def reset_current_consumer
|
128
|
+
@consumers[@consumer_id_iterator.peek] = nil
|
129
|
+
end
|
130
|
+
|
131
|
+
def maybe_select_next_consumer
|
132
|
+
return unless @last_poll_read_nil_message
|
133
|
+
@last_poll_read_nil_message = false
|
134
|
+
select_next_consumer
|
135
|
+
end
|
136
|
+
|
137
|
+
def select_next_consumer
|
138
|
+
@consumer_id_iterator.next
|
139
|
+
end
|
140
|
+
|
141
|
+
def commit_rescue_no_offset(consumer)
|
142
|
+
consumer.commit(nil, !@config.synchronous_commits)
|
143
|
+
rescue Rdkafka::RdkafkaError => e
|
144
|
+
raise e if e.code != :no_offset
|
145
|
+
@logger.debug "Nothing to commit."
|
146
|
+
end
|
147
|
+
|
148
|
+
def collect_messages_for_batch?
|
149
|
+
@messages.size < @config.fetch_messages &&
|
150
|
+
(Time.now - @batch_started_at) < @config.max_wait_time
|
151
|
+
end
|
152
|
+
|
153
|
+
def rdkafka_config(subscription)
|
154
|
+
# https://github.com/edenhill/librdkafka/blob/master/CONFIGURATION.md
|
155
|
+
config = {
|
156
|
+
"auto.commit.interval.ms" => @config.offset_commit_interval * 1000,
|
157
|
+
"auto.offset.reset" => subscription.start_from_beginning ? "earliest" : "largest",
|
158
|
+
"bootstrap.servers" => @config.brokers.join(","),
|
159
|
+
"client.id" => @config.client_id,
|
160
|
+
"enable.partition.eof" => false,
|
161
|
+
"fetch.max.bytes" => @config.max_bytes,
|
162
|
+
"fetch.message.max.bytes" => subscription.max_bytes_per_partition,
|
163
|
+
"fetch.wait.max.ms" => @config.max_wait_time * 1000,
|
164
|
+
"group.id" => @config.group_id,
|
165
|
+
"heartbeat.interval.ms" => @config.heartbeat_interval * 1000,
|
166
|
+
"max.poll.interval.ms" => @config.max_poll_interval * 1000,
|
167
|
+
"queued.min.messages" => @config.min_message_queue_size,
|
168
|
+
"session.timeout.ms" => @config.session_timeout * 1000,
|
169
|
+
"socket.timeout.ms" => @config.socket_timeout * 1000,
|
170
|
+
"statistics.interval.ms" => 1000, # 1s is the highest granularity offered
|
171
|
+
}
|
172
|
+
config.merge! @config.rdkafka_consumer
|
173
|
+
config.merge! subscription.additional_config
|
174
|
+
config
|
175
|
+
end
|
176
|
+
end
|
177
|
+
end
|
data/lib/racecar/ctl.rb
CHANGED
@@ -93,15 +93,12 @@ module Racecar
|
|
93
93
|
|
94
94
|
Racecar.config.validate!
|
95
95
|
|
96
|
-
|
97
|
-
|
98
|
-
|
99
|
-
|
100
|
-
|
101
|
-
|
102
|
-
)
|
103
|
-
|
104
|
-
kafka.deliver_message(message.value, key: message.key, topic: message.topic)
|
96
|
+
producer = Rdkafka::Config.new({
|
97
|
+
"bootstrap.servers": Racecar.config.brokers.join(","),
|
98
|
+
"client.id": Racecar.config.client_id,
|
99
|
+
}.merge(Racecar.config.rdkafka_producer)).producer
|
100
|
+
|
101
|
+
producer.produce(payload: message.value, key: message.key, topic: message.topic).wait
|
105
102
|
|
106
103
|
$stderr.puts "=> Delivered message to Kafka cluster"
|
107
104
|
end
|
@@ -0,0 +1,55 @@
|
|
1
|
+
module Racecar
|
2
|
+
class Pause
|
3
|
+
def initialize(timeout: nil, max_timeout: nil, exponential_backoff: false)
|
4
|
+
@started_at = nil
|
5
|
+
@pauses = 0
|
6
|
+
@timeout = timeout
|
7
|
+
@max_timeout = max_timeout
|
8
|
+
@exponential_backoff = exponential_backoff
|
9
|
+
end
|
10
|
+
|
11
|
+
def pause!
|
12
|
+
@started_at = Time.now
|
13
|
+
@ends_at = @started_at + backoff_interval unless @timeout.nil?
|
14
|
+
@pauses += 1
|
15
|
+
end
|
16
|
+
|
17
|
+
def resume!
|
18
|
+
@started_at = nil
|
19
|
+
@ends_at = nil
|
20
|
+
end
|
21
|
+
|
22
|
+
def paused?
|
23
|
+
!@started_at.nil?
|
24
|
+
end
|
25
|
+
|
26
|
+
def pause_duration
|
27
|
+
if paused?
|
28
|
+
Time.now - @started_at
|
29
|
+
else
|
30
|
+
0
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
def expired?
|
35
|
+
return false if @timeout.nil?
|
36
|
+
return true unless @ends_at
|
37
|
+
Time.now >= @ends_at
|
38
|
+
end
|
39
|
+
|
40
|
+
def reset!
|
41
|
+
@pauses = 0
|
42
|
+
end
|
43
|
+
|
44
|
+
def backoff_interval
|
45
|
+
return Float::INFINITY if @timeout.nil?
|
46
|
+
|
47
|
+
backoff_factor = @exponential_backoff ? 2**@pauses : 1
|
48
|
+
timeout = backoff_factor * @timeout
|
49
|
+
|
50
|
+
timeout = @max_timeout if @max_timeout && timeout > @max_timeout
|
51
|
+
|
52
|
+
timeout
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
data/lib/racecar/runner.rb
CHANGED
@@ -1,151 +1,221 @@
|
|
1
|
-
require "
|
1
|
+
require "rdkafka"
|
2
|
+
require "racecar/pause"
|
2
3
|
|
3
4
|
module Racecar
|
4
5
|
class Runner
|
5
|
-
attr_reader :processor, :config, :logger
|
6
|
+
attr_reader :processor, :config, :logger
|
6
7
|
|
7
8
|
def initialize(processor, config:, logger:, instrumenter: NullInstrumenter)
|
8
9
|
@processor, @config, @logger = processor, config, logger
|
9
10
|
@instrumenter = instrumenter
|
11
|
+
@stop_requested = false
|
12
|
+
Rdkafka::Config.logger = logger
|
13
|
+
|
14
|
+
if processor.respond_to?(:statistics_callback)
|
15
|
+
Rdkafka::Config.statistics_callback = processor.method(:statistics_callback).to_proc
|
16
|
+
end
|
17
|
+
|
18
|
+
setup_pauses
|
10
19
|
end
|
11
20
|
|
12
|
-
def
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
21
|
+
def setup_pauses
|
22
|
+
timeout = if config.pause_timeout == -1
|
23
|
+
nil
|
24
|
+
elsif config.pause_timeout == 0
|
25
|
+
# no op, handled elsewhere
|
26
|
+
elsif config.pause_timeout > 0
|
27
|
+
config.pause_timeout
|
28
|
+
else
|
29
|
+
raise ArgumentError, "Invalid value for pause_timeout: must be integer greater or equal -1"
|
30
|
+
end
|
31
|
+
|
32
|
+
@pauses = Hash.new {|h, k|
|
33
|
+
h[k] = Hash.new {|h2, k2|
|
34
|
+
h2[k2] = ::Racecar::Pause.new(
|
35
|
+
timeout: timeout,
|
36
|
+
max_timeout: config.max_pause_timeout,
|
37
|
+
exponential_backoff: config.pause_with_exponential_backoff
|
38
|
+
)
|
39
|
+
}
|
40
|
+
}
|
17
41
|
end
|
18
42
|
|
19
43
|
def run
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
ssl_client_cert_key: config.ssl_client_cert_key,
|
30
|
-
ssl_client_cert_key_password: config.ssl_client_cert_key_password,
|
31
|
-
sasl_plain_username: config.sasl_plain_username,
|
32
|
-
sasl_plain_password: config.sasl_plain_password,
|
33
|
-
sasl_scram_username: config.sasl_scram_username,
|
34
|
-
sasl_scram_password: config.sasl_scram_password,
|
35
|
-
sasl_scram_mechanism: config.sasl_scram_mechanism,
|
36
|
-
sasl_oauth_token_provider: config.sasl_oauth_token_provider,
|
37
|
-
sasl_over_ssl: config.sasl_over_ssl,
|
38
|
-
ssl_ca_certs_from_system: config.ssl_ca_certs_from_system,
|
39
|
-
ssl_verify_hostname: config.ssl_verify_hostname
|
44
|
+
install_signal_handlers
|
45
|
+
@stop_requested = false
|
46
|
+
|
47
|
+
# Configure the consumer with a producer so it can produce messages and
|
48
|
+
# with a consumer so that it can support advanced use-cases.
|
49
|
+
processor.configure(
|
50
|
+
producer: producer,
|
51
|
+
consumer: consumer,
|
52
|
+
instrumenter: @instrumenter,
|
40
53
|
)
|
41
54
|
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
55
|
+
instrument_payload = { consumer_class: processor.class.to_s, consumer_set: consumer }
|
56
|
+
|
57
|
+
# Main loop
|
58
|
+
loop do
|
59
|
+
break if @stop_requested
|
60
|
+
resume_paused_partitions
|
61
|
+
@instrumenter.instrument("main_loop.racecar", instrument_payload) do
|
62
|
+
case process_method
|
63
|
+
when :batch then
|
64
|
+
msg_per_part = consumer.batch_poll(config.max_wait_time).group_by(&:partition)
|
65
|
+
msg_per_part.each_value do |messages|
|
66
|
+
process_batch(messages)
|
67
|
+
end
|
68
|
+
when :single then
|
69
|
+
message = consumer.poll(config.max_wait_time)
|
70
|
+
process(message) if message
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
74
|
+
|
75
|
+
logger.info "Gracefully shutting down"
|
76
|
+
processor.deliver!
|
77
|
+
processor.teardown
|
78
|
+
consumer.commit
|
79
|
+
consumer.close
|
80
|
+
end
|
51
81
|
|
82
|
+
def stop
|
83
|
+
@stop_requested = true
|
84
|
+
end
|
85
|
+
|
86
|
+
private
|
87
|
+
|
88
|
+
attr_reader :pauses
|
89
|
+
|
90
|
+
def process_method
|
91
|
+
@process_method ||= begin
|
92
|
+
case
|
93
|
+
when processor.respond_to?(:process_batch) then :batch
|
94
|
+
when processor.respond_to?(:process) then :single
|
95
|
+
else
|
96
|
+
raise NotImplementedError, "Consumer class must implement process or process_batch method"
|
97
|
+
end
|
98
|
+
end
|
99
|
+
end
|
100
|
+
|
101
|
+
def consumer
|
102
|
+
@consumer ||= begin
|
103
|
+
# Manually store offset after messages have been processed successfully
|
104
|
+
# to avoid marking failed messages as committed. The call just updates
|
105
|
+
# a value within librdkafka and is asynchronously written to proper
|
106
|
+
# storage through auto commits.
|
107
|
+
config.consumer << "enable.auto.offset.store=false"
|
108
|
+
ConsumerSet.new(config, logger)
|
109
|
+
end
|
110
|
+
end
|
111
|
+
|
112
|
+
def producer
|
113
|
+
@producer ||= Rdkafka::Config.new(producer_config).producer.tap do |producer|
|
114
|
+
producer.delivery_callback = delivery_callback
|
115
|
+
end
|
116
|
+
end
|
117
|
+
|
118
|
+
def producer_config
|
119
|
+
# https://github.com/edenhill/librdkafka/blob/master/CONFIGURATION.md
|
120
|
+
producer_config = {
|
121
|
+
"bootstrap.servers" => config.brokers.join(","),
|
122
|
+
"client.id" => config.client_id,
|
123
|
+
"statistics.interval.ms" => 1000,
|
124
|
+
}
|
125
|
+
producer_config["compression.codec"] = config.producer_compression_codec.to_s unless config.producer_compression_codec.nil?
|
126
|
+
producer_config.merge!(config.rdkafka_producer)
|
127
|
+
producer_config
|
128
|
+
end
|
129
|
+
|
130
|
+
def delivery_callback
|
131
|
+
->(delivery_report) do
|
132
|
+
data = {offset: delivery_report.offset, partition: delivery_report.partition}
|
133
|
+
@instrumenter.instrument("acknowledged_message.racecar", data)
|
134
|
+
end
|
135
|
+
end
|
136
|
+
|
137
|
+
def install_signal_handlers
|
52
138
|
# Stop the consumer on SIGINT, SIGQUIT or SIGTERM.
|
53
139
|
trap("QUIT") { stop }
|
54
|
-
trap("INT")
|
140
|
+
trap("INT") { stop }
|
55
141
|
trap("TERM") { stop }
|
56
142
|
|
57
143
|
# Print the consumer config to STDERR on USR1.
|
58
144
|
trap("USR1") { $stderr.puts config.inspect }
|
145
|
+
end
|
59
146
|
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
147
|
+
def process(message)
|
148
|
+
payload = {
|
149
|
+
consumer_class: processor.class.to_s,
|
150
|
+
topic: message.topic,
|
151
|
+
partition: message.partition,
|
152
|
+
offset: message.offset,
|
153
|
+
}
|
154
|
+
|
155
|
+
@instrumenter.instrument("process_message.racecar", payload) do
|
156
|
+
with_pause(message.topic, message.partition, message.offset..message.offset) do
|
157
|
+
processor.process(message)
|
158
|
+
processor.deliver!
|
159
|
+
consumer.store_offset(message)
|
160
|
+
end
|
66
161
|
end
|
162
|
+
end
|
67
163
|
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
164
|
+
def process_batch(messages)
|
165
|
+
payload = {
|
166
|
+
consumer_class: processor.class.to_s,
|
167
|
+
topic: messages.first.topic,
|
168
|
+
partition: messages.first.partition,
|
169
|
+
first_offset: messages.first.offset,
|
170
|
+
message_count: messages.size,
|
171
|
+
}
|
172
|
+
|
173
|
+
@instrumenter.instrument("process_batch.racecar", payload) do
|
174
|
+
first, last = messages.first, messages.last
|
175
|
+
with_pause(first.topic, first.partition, first.offset..last.offset) do
|
176
|
+
processor.process_batch(messages)
|
177
|
+
processor.deliver!
|
178
|
+
consumer.store_offset(messages.last)
|
179
|
+
end
|
180
|
+
end
|
181
|
+
end
|
72
182
|
|
73
|
-
|
183
|
+
def with_pause(topic, partition, offsets)
|
184
|
+
return yield if config.pause_timeout == 0
|
74
185
|
|
75
186
|
begin
|
76
|
-
|
77
|
-
|
78
|
-
|
79
|
-
|
80
|
-
|
81
|
-
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
-
|
90
|
-
|
91
|
-
|
92
|
-
|
93
|
-
|
94
|
-
|
95
|
-
|
96
|
-
|
97
|
-
|
98
|
-
|
99
|
-
|
100
|
-
|
101
|
-
|
102
|
-
|
103
|
-
|
187
|
+
yield
|
188
|
+
# We've successfully processed a batch from the partition, so we can clear the pause.
|
189
|
+
pauses[topic][partition].reset!
|
190
|
+
rescue => e
|
191
|
+
desc = "#{topic}/#{partition}"
|
192
|
+
logger.error "Failed to process #{desc} at #{offsets}: #{e}"
|
193
|
+
|
194
|
+
pause = pauses[topic][partition]
|
195
|
+
logger.warn "Pausing partition #{desc} for #{pause.backoff_interval} seconds"
|
196
|
+
consumer.pause(topic, partition, offsets.first)
|
197
|
+
pause.pause!
|
198
|
+
end
|
199
|
+
end
|
200
|
+
|
201
|
+
def resume_paused_partitions
|
202
|
+
return if config.pause_timeout == 0
|
203
|
+
|
204
|
+
pauses.each do |topic, partitions|
|
205
|
+
partitions.each do |partition, pause|
|
206
|
+
@instrumenter.instrument("pause_status.racecar", {
|
207
|
+
topic: topic,
|
208
|
+
partition: partition,
|
209
|
+
duration: pause.pause_duration,
|
210
|
+
})
|
211
|
+
|
212
|
+
if pause.paused? && pause.expired?
|
213
|
+
logger.info "Automatically resuming partition #{topic}/#{partition}, pause timeout expired"
|
214
|
+
consumer.resume(topic, partition)
|
215
|
+
pause.resume!
|
216
|
+
# TODO: # During re-balancing we might have lost the paused partition. Check if partition is still in group before seek. ?
|
104
217
|
end
|
105
|
-
else
|
106
|
-
raise NotImplementedError, "Consumer class must implement process or process_batch method"
|
107
218
|
end
|
108
|
-
rescue Kafka::ProcessingError => e
|
109
|
-
@logger.error "Error processing partition #{e.topic}/#{e.partition} at offset #{e.offset}"
|
110
|
-
|
111
|
-
if config.pause_timeout > 0
|
112
|
-
# Pause fetches from the partition. We'll continue processing the other partitions in the topic.
|
113
|
-
# The partition is automatically resumed after the specified timeout, and will continue where we
|
114
|
-
# left off.
|
115
|
-
@logger.warn "Pausing partition #{e.topic}/#{e.partition} for #{config.pause_timeout} seconds"
|
116
|
-
consumer.pause(
|
117
|
-
e.topic,
|
118
|
-
e.partition,
|
119
|
-
timeout: config.pause_timeout,
|
120
|
-
max_timeout: config.max_pause_timeout,
|
121
|
-
exponential_backoff: config.pause_with_exponential_backoff?,
|
122
|
-
)
|
123
|
-
elsif config.pause_timeout == -1
|
124
|
-
# A pause timeout of -1 means indefinite pausing, which in ruby-kafka is done by passing nil as
|
125
|
-
# the timeout.
|
126
|
-
@logger.warn "Pausing partition #{e.topic}/#{e.partition} indefinitely, or until the process is restarted"
|
127
|
-
consumer.pause(e.topic, e.partition, timeout: nil)
|
128
|
-
end
|
129
|
-
|
130
|
-
config.error_handler.call(e.cause, {
|
131
|
-
topic: e.topic,
|
132
|
-
partition: e.partition,
|
133
|
-
offset: e.offset,
|
134
|
-
})
|
135
|
-
|
136
|
-
# Restart the consumer loop.
|
137
|
-
retry
|
138
|
-
rescue Kafka::InvalidSessionTimeout
|
139
|
-
raise ConfigError, "`session_timeout` is set either too high or too low"
|
140
|
-
rescue Kafka::Error => e
|
141
|
-
error = "#{e.class}: #{e.message}\n" + e.backtrace.join("\n")
|
142
|
-
@logger.error "Consumer thread crashed: #{error}"
|
143
|
-
|
144
|
-
config.error_handler.call(e)
|
145
|
-
|
146
|
-
raise
|
147
|
-
else
|
148
|
-
@logger.info "Gracefully shutting down"
|
149
219
|
end
|
150
220
|
end
|
151
221
|
end
|