Skip to content

Commit 2900f6a

Browse files
authored
Producer support for per-topic configs (#449)
1 parent 4eb360f commit 2900f6a

File tree

4 files changed

+141
-4
lines changed

4 files changed

+141
-4
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
## 0.16.0 (Unreleased)
44
- **[Feature]** Support incremental config describe + alter API.
55
- **[Feature]** Oauthbearer token refresh callback (bruce-szalwinski-he)
6+
- **[Feature]** Provide ability to use topic config on a producer for custom behaviors per dispatch.
7+
- [Enhancement] Use topic config reference cache for messages production to prevent topic objects allocation with each message.
68
- [Enhancement] Provide `Rrdkafka::Admin#describe_errors` to get errors descriptions (mensfeld)
79
- [Enhancement] Replace time poll based wait engine with an event based to improve response times on blocking operations and wait (nijikon + mensfeld)
810
- [Enhancement] Allow for usage of the second regex engine of librdkafka by setting `RDKAFKA_DISABLE_REGEX_EXT` during build (mensfeld)

lib/rdkafka/bindings.rb

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,9 @@ class NativeErrorDesc < FFI::Struct
167167
# Log queue
168168
attach_function :rd_kafka_set_log_queue, [:pointer, :pointer], :void
169169
attach_function :rd_kafka_queue_get_main, [:pointer], :pointer
170+
# Per topic configs
171+
attach_function :rd_kafka_topic_conf_new, [], :pointer
172+
attach_function :rd_kafka_topic_conf_set, [:pointer, :string, :string, :pointer, :int], :kafka_config_response
170173

171174
LogCallback = FFI::Function.new(
172175
:void, [:pointer, :int, :string, :string]

lib/rdkafka/producer.rb

Lines changed: 94 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,15 @@ class Producer
99
# Cache partitions count for 30 seconds
1010
PARTITIONS_COUNT_TTL = 30
1111

12-
private_constant :PARTITIONS_COUNT_TTL
12+
# Empty hash used as a default
13+
EMPTY_HASH = {}.freeze
14+
15+
private_constant :PARTITIONS_COUNT_TTL, :EMPTY_HASH
16+
17+
# Raised when there was a critical issue when invoking rd_kafka_topic_new
18+
# This is a temporary solution until https://github.com/karafka/rdkafka-ruby/issues/451 is
19+
# resolved and this is normalized in all the places
20+
class TopicHandleCreationError < RuntimeError; end
1321

1422
# @private
1523
# Returns the current delivery callback, by default this is nil.
@@ -28,6 +36,8 @@ class Producer
2836
# @param partitioner_name [String, nil] name of the partitioner we want to use or nil to use
2937
# the "consistent_random" default
3038
def initialize(native_kafka, partitioner_name)
39+
@topics_refs_map = {}
40+
@topics_configs = {}
3141
@native_kafka = native_kafka
3242
@partitioner_name = partitioner_name || "consistent_random"
3343

@@ -54,6 +64,52 @@ def initialize(native_kafka, partitioner_name)
5464
end
5565
end
5666

67+
# Sets alternative set of configuration details that can be set per topic
68+
# @note It is not allowed to re-set the same topic config twice because of the underlying
69+
# librdkafka caching
70+
# @param topic [String] The topic name
71+
# @param config [Hash] config we want to use per topic basis
72+
# @param config_hash [Integer] hash of the config. We expect it here instead of computing it,
73+
# because it is already computed during the retrieval attempt in the `#produce` flow.
74+
def set_topic_config(topic, config, config_hash)
75+
# Ensure lock on topic reference just in case
76+
@native_kafka.with_inner do |inner|
77+
@topics_refs_map[topic] ||= {}
78+
@topics_configs[topic] ||= {}
79+
80+
return if @topics_configs[topic].key?(config_hash)
81+
82+
# If config is empty, we create an empty reference that will be used with defaults
83+
rd_topic_config = if config.empty?
84+
nil
85+
else
86+
Rdkafka::Bindings.rd_kafka_topic_conf_new.tap do |topic_config|
87+
config.each do |key, value|
88+
error_buffer = FFI::MemoryPointer.new(:char, 256)
89+
result = Rdkafka::Bindings.rd_kafka_topic_conf_set(
90+
topic_config,
91+
key.to_s,
92+
value.to_s,
93+
error_buffer,
94+
256
95+
)
96+
97+
unless result == :config_ok
98+
raise Config::ConfigError.new(error_buffer.read_string)
99+
end
100+
end
101+
end
102+
end
103+
104+
topic_handle = Bindings.rd_kafka_topic_new(inner, topic, rd_topic_config)
105+
106+
raise TopicHandleCreationError.new("Error creating topic handle for topic #{topic}") if topic_handle.null?
107+
108+
@topics_configs[topic][config_hash] = config
109+
@topics_refs_map[topic][config_hash] = topic_handle
110+
end
111+
end
112+
57113
# Starts the native Kafka polling thread and kicks off the init polling
58114
# @note Not needed to run unless explicit start was disabled
59115
def start
@@ -83,7 +139,18 @@ def delivery_callback=(callback)
83139
def close
84140
return if closed?
85141
ObjectSpace.undefine_finalizer(self)
86-
@native_kafka.close
142+
143+
@native_kafka.close do
144+
# We need to remove the topics references objects before we destroy the producer,
145+
# otherwise they would leak out
146+
@topics_refs_map.each_value do |refs|
147+
refs.each_value do |ref|
148+
Rdkafka::Bindings.rd_kafka_topic_destroy(ref)
149+
end
150+
end
151+
end
152+
153+
@topics_refs_map.clear
87154
end
88155

89156
# Whether this producer has closed
@@ -182,11 +249,22 @@ def partition_count(topic)
182249
# @param timestamp [Time,Integer,nil] Optional timestamp of this message. Integer timestamp is in milliseconds since Jan 1 1970.
183250
# @param headers [Hash<String,String>] Optional message headers
184251
# @param label [Object, nil] a label that can be assigned when producing a message that will be part of the delivery handle and the delivery report
252+
# @param topic_config [Hash] topic config for given message dispatch. Allows to send messages to topics with different configuration
185253
#
186254
# @return [DeliveryHandle] Delivery handle that can be used to wait for the result of producing this message
187255
#
188256
# @raise [RdkafkaError] When adding the message to rdkafka's queue failed
189-
def produce(topic:, payload: nil, key: nil, partition: nil, partition_key: nil, timestamp: nil, headers: nil, label: nil)
257+
def produce(
258+
topic:,
259+
payload: nil,
260+
key: nil,
261+
partition: nil,
262+
partition_key: nil,
263+
timestamp: nil,
264+
headers: nil,
265+
label: nil,
266+
topic_config: EMPTY_HASH
267+
)
190268
closed_producer_check(__method__)
191269

192270
# Start by checking and converting the input
@@ -205,8 +283,20 @@ def produce(topic:, payload: nil, key: nil, partition: nil, partition_key: nil,
205283
key.bytesize
206284
end
207285

286+
topic_config_hash = topic_config.hash
287+
288+
# Checks if we have the rdkafka topic reference object ready. It saves us on object
289+
# allocation and allows to use custom config on demand.
290+
set_topic_config(topic, topic_config, topic_config_hash) unless @topics_refs_map.dig(topic, topic_config_hash)
291+
topic_ref = @topics_refs_map.dig(topic, topic_config_hash)
292+
208293
if partition_key
209294
partition_count = partition_count(topic)
295+
296+
# Check if there are no overrides for the partitioner and use the default one only when
297+
# no per-topic is present.
298+
partitioner_name = @topics_configs.dig(topic, topic_config_hash, :partitioner) || @partitioner_name
299+
210300
# If the topic is not present, set to -1
211301
partition = Rdkafka::Bindings.partitioner(partition_key, partition_count, @partitioner_name) if partition_count.positive?
212302
end
@@ -236,7 +326,7 @@ def produce(topic:, payload: nil, key: nil, partition: nil, partition_key: nil,
236326
DeliveryHandle.register(delivery_handle)
237327

238328
args = [
239-
:int, Rdkafka::Bindings::RD_KAFKA_VTYPE_TOPIC, :string, topic,
329+
:int, Rdkafka::Bindings::RD_KAFKA_VTYPE_RKT, :pointer, topic_ref,
240330
:int, Rdkafka::Bindings::RD_KAFKA_VTYPE_MSGFLAGS, :int, Rdkafka::Bindings::RD_KAFKA_MSG_F_COPY,
241331
:int, Rdkafka::Bindings::RD_KAFKA_VTYPE_VALUE, :buffer_in, payload, :size_t, payload_size,
242332
:int, Rdkafka::Bindings::RD_KAFKA_VTYPE_KEY, :buffer_in, key, :size_t, key_size,

spec/rdkafka/producer_spec.rb

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,48 @@
3131
it { expect(producer.name).to include('rdkafka#producer-') }
3232
end
3333

34+
describe '#produce with topic config alterations' do
35+
context 'when config is not valid' do
36+
it 'expect to raise error' do
37+
expect do
38+
producer.produce(topic: 'test', payload: '', topic_config: { 'invalid': 'invalid' })
39+
end.to raise_error(Rdkafka::Config::ConfigError)
40+
end
41+
end
42+
43+
context 'when config is valid' do
44+
it 'expect to raise error' do
45+
expect do
46+
producer.produce(topic: 'test', payload: '', topic_config: { 'acks': 1 }).wait
47+
end.not_to raise_error
48+
end
49+
50+
context 'when alteration should change behavior' do
51+
# This is set incorrectly for a reason
52+
# If alteration would not work, this will hang the spec suite
53+
let(:producer) do
54+
rdkafka_producer_config(
55+
'message.timeout.ms': 1_000_000,
56+
:"bootstrap.servers" => "localhost:9094",
57+
).producer
58+
end
59+
60+
it 'expect to give up on delivery fast based on alteration config' do
61+
expect do
62+
producer.produce(
63+
topic: 'produce_config_test',
64+
payload: 'test',
65+
topic_config: {
66+
'compression.type': 'gzip',
67+
'message.timeout.ms': 1
68+
}
69+
).wait
70+
end.to raise_error(Rdkafka::RdkafkaError, /msg_timed_out/)
71+
end
72+
end
73+
end
74+
end
75+
3476
context "delivery callback" do
3577
context "with a proc/lambda" do
3678
it "should set the callback" do

0 commit comments

Comments
 (0)