karafka-rdkafka 0.30.2-aarch64-linux-gnu → 0.32.0-aarch64-linux-gnu

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 (63) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +100 -36
  3. data/README.md +3 -1
  4. data/docker-compose-ssl.yml +1 -0
  5. data/docker-compose.yml +1 -0
  6. data/ext/librdkafka.so +0 -0
  7. data/karafka-rdkafka.gemspec +5 -4
  8. data/lib/rdkafka/admin/acl_binding_result.rb +4 -16
  9. data/lib/rdkafka/admin/alter_consumer_group_offsets_report.rb +2 -2
  10. data/lib/rdkafka/admin/create_acl_report.rb +1 -3
  11. data/lib/rdkafka/admin/create_topic_report.rb +2 -6
  12. data/lib/rdkafka/admin/delete_consumer_group_offsets_report.rb +2 -2
  13. data/lib/rdkafka/admin/delete_groups_report.rb +2 -6
  14. data/lib/rdkafka/admin/delete_topic_report.rb +2 -6
  15. data/lib/rdkafka/admin/describe_cluster_handle.rb +31 -0
  16. data/lib/rdkafka/admin/describe_cluster_report.rb +51 -0
  17. data/lib/rdkafka/admin/describe_consumer_groups_handle.rb +31 -0
  18. data/lib/rdkafka/admin/describe_consumer_groups_report.rb +151 -0
  19. data/lib/rdkafka/admin/describe_result_parsing.rb +47 -0
  20. data/lib/rdkafka/admin/describe_topics_handle.rb +31 -0
  21. data/lib/rdkafka/admin/describe_topics_report.rb +118 -0
  22. data/lib/rdkafka/admin/list_consumer_group_offsets_handle.rb +31 -0
  23. data/lib/rdkafka/admin/list_consumer_group_offsets_report.rb +52 -0
  24. data/lib/rdkafka/admin.rb +424 -9
  25. data/lib/rdkafka/bindings/admin/acls.rb +85 -0
  26. data/lib/rdkafka/bindings/admin/configs.rb +37 -0
  27. data/lib/rdkafka/bindings/admin/list_offsets.rb +26 -0
  28. data/lib/rdkafka/bindings/admin.rb +211 -0
  29. data/lib/rdkafka/bindings/callbacks.rb +155 -0
  30. data/lib/rdkafka/bindings/configuration.rb +50 -0
  31. data/lib/rdkafka/bindings/consumer.rb +125 -0
  32. data/lib/rdkafka/bindings/errors.rb +17 -0
  33. data/lib/rdkafka/bindings/handle.rb +16 -0
  34. data/lib/rdkafka/bindings/messages.rb +28 -0
  35. data/lib/rdkafka/bindings/metadata.rb +17 -0
  36. data/lib/rdkafka/bindings/polling.rb +17 -0
  37. data/lib/rdkafka/bindings/producer.rb +82 -0
  38. data/lib/rdkafka/bindings/share_consumer.rb +67 -0
  39. data/lib/rdkafka/bindings/structs.rb +62 -0
  40. data/lib/rdkafka/bindings.rb +16 -775
  41. data/lib/rdkafka/callbacks/describe_cluster_handler.rb +36 -0
  42. data/lib/rdkafka/callbacks/describe_consumer_groups_handler.rb +36 -0
  43. data/lib/rdkafka/callbacks/describe_topics_handler.rb +36 -0
  44. data/lib/rdkafka/callbacks/list_consumer_group_offsets_handler.rb +62 -0
  45. data/lib/rdkafka/callbacks.rb +15 -7
  46. data/lib/rdkafka/clients.rb +13 -46
  47. data/lib/rdkafka/config.rb +70 -0
  48. data/lib/rdkafka/consumer/allocator.rb +0 -2
  49. data/lib/rdkafka/consumer/message.rb +6 -9
  50. data/lib/rdkafka/consumer/topic_partition_list.rb +7 -7
  51. data/lib/rdkafka/consumer.rb +76 -36
  52. data/lib/rdkafka/defaults.rb +6 -4
  53. data/lib/rdkafka/native_kafka.rb +45 -31
  54. data/lib/rdkafka/producer/delivery_handle.rb +18 -16
  55. data/lib/rdkafka/producer/delivery_report.rb +51 -1
  56. data/lib/rdkafka/producer/partitions_count_cache.rb +109 -26
  57. data/lib/rdkafka/producer.rb +141 -71
  58. data/lib/rdkafka/share_consumer.rb +70 -34
  59. data/lib/rdkafka/version.rb +3 -3
  60. data/lib/rdkafka.rb +14 -1
  61. data/package-lock.json +9 -9
  62. data/renovate.json +7 -0
  63. metadata +31 -31
@@ -42,9 +42,30 @@ module Rdkafka
42
42
  # an authoritative metadata read and adopts the lower count. Within the TTL window a lower
43
43
  # value is still ignored, so a transient or racy lower read cannot clobber a correct higher
44
44
  # count.
45
+ #
46
+ # 7. Cluster outages
47
+ # If the refresh of an expired entry fails on a transient error (for example all brokers
48
+ # are unreachable), the last known count is served instead of raising, and the next refresh
49
+ # is attempted after {Defaults::PARTITIONS_COUNT_CACHE_STALE_BACKOFF_MS}. This lets
50
+ # `partition_key` produces keep buffering in librdkafka during an outage. Without a known
51
+ # count the error is raised. Only one caller refreshes an expired entry at a time; the
52
+ # others get the known count meanwhile.
45
53
  class PartitionsCountCache
46
54
  include Helpers::Time
47
55
 
56
+ # Refresh errors after which the last known count is still served
57
+ STALE_FALLBACK_ERRORS = %i[
58
+ transport
59
+ timed_out
60
+ all_brokers_down
61
+ resolve
62
+ ].freeze
63
+
64
+ private_constant :STALE_FALLBACK_ERRORS
65
+
66
+ # @return [Integer] time-to-live in milliseconds for cached values
67
+ attr_reader :ttl_ms
68
+
48
69
  # Creates a new partition count cache
49
70
  #
50
71
  # @param ttl [Integer, nil] DEPRECATED: Use ttl_ms instead.
@@ -73,33 +94,44 @@ module Rdkafka
73
94
  end
74
95
  end
75
96
 
97
+ # Changes the time-to-live of cached values. Cached entries are kept and expire based on the
98
+ # new value.
99
+ #
100
+ # @param value [Integer] time-to-live in milliseconds
101
+ # @raise [ArgumentError] when the value is not a positive Integer
102
+ def ttl_ms=(value)
103
+ raise ArgumentError.new("ttl_ms has to be a positive Integer") unless value.is_a?(Integer) && value.positive?
104
+
105
+ @ttl_ms = value
106
+ end
107
+
76
108
  # Reads partition count for a topic with automatic refresh when expired
77
109
  #
78
110
  # This method will return the cached partition count if available and not expired.
79
111
  # If the value is expired or not available, it will execute the provided block
80
112
  # to fetch the current value from Kafka.
81
113
  #
82
- # @param topic [String] Kafka topic name
114
+ # @param topic_name [String] Kafka topic name
83
115
  # @yield Block that returns the current partition count when cache needs refreshing
84
116
  # @yieldreturn [Integer] Current partition count retrieved from Kafka
85
117
  # @return [Integer] Partition count for the topic
118
+ # @raise [RdkafkaError] when the refresh fails and there is no last known count to serve, or
119
+ # when it fails on a non-transient error
86
120
  #
87
121
  # @note The implementation prioritizes read performance over write consistency
88
122
  # since partition counts typically only increase during normal operation.
89
- def get(topic)
90
- current_info = @counts[topic]
91
-
92
- if current_info.nil? || expired?(current_info[0])
93
- # The cached entry is missing or expired, so the block performs an authoritative metadata
94
- # read. We hand it to `set`, which adopts a higher count always and a lower count once the
95
- # entry has expired (e.g. the topic was recreated with fewer partitions). We then return
96
- # whatever `set` settled on so a concurrent refresh that wrote a higher value still wins.
123
+ def get(topic_name)
124
+ current_info = @counts[topic_name]
125
+
126
+ if current_info.nil?
97
127
  new_count = yield
98
- set(topic, new_count)
128
+ set(topic_name, new_count)
99
129
 
100
- return @counts[topic][1]
130
+ return @counts[topic_name][1]
101
131
  end
102
132
 
133
+ return refresh(topic_name, current_info) { yield } if expired?(current_info[0])
134
+
103
135
  current_info[1]
104
136
  end
105
137
 
@@ -108,34 +140,34 @@ module Rdkafka
108
140
  # This method updates the partition count for a topic in the cache.
109
141
  # It uses a mutex to ensure thread-safety during updates.
110
142
  #
111
- # @param topic [String] Kafka topic name
143
+ # @param topic_name [String] Kafka topic name
112
144
  # @param new_count [Integer] New partition count value
113
145
  #
114
146
  # @note We prioritize higher partition counts and only accept them when using
115
147
  # a mutex to ensure consistency. This design decision is based on the fact that
116
148
  # partition counts in Kafka only increase during normal operation.
117
- def set(topic, new_count)
149
+ def set(topic_name, new_count)
118
150
  # First check outside mutex to avoid unnecessary locking
119
- current_info = @counts[topic]
151
+ current_info = @counts[topic_name]
120
152
 
121
153
  # Within the TTL window a lower value is treated as a stale/racy read and ignored, since
122
154
  # partition counts only grow during normal operation. Once the entry has expired a lower
123
155
  # value is an authoritative refresh (e.g. the topic was recreated with fewer partitions),
124
156
  # so we fall through and adopt it below.
125
157
  if current_info && new_count < current_info[1] && !expired?(current_info[0])
126
- refresh_timestamp(topic)
158
+ refresh_timestamp(topic_name)
127
159
 
128
160
  return
129
161
  end
130
162
 
131
163
  # Only lock the specific topic mutex
132
- mutex_for(topic).synchronize do
164
+ mutex_for(topic_name).synchronize do
133
165
  # Check again inside the lock as another thread might have updated
134
- current_info = @counts[topic]
166
+ current_info = @counts[topic_name]
135
167
 
136
168
  if current_info.nil?
137
169
  # Create new entry
138
- @counts[topic] = [monotonic_now_ms, new_count]
170
+ @counts[topic_name] = [monotonic_now_ms, new_count]
139
171
  elsif new_count > current_info[1] || expired?(current_info[0])
140
172
  # A higher count always wins; a lower count is accepted only when the existing entry
141
173
  # has expired, so a concurrent fresh higher value (which reset the timestamp) is never
@@ -161,24 +193,24 @@ module Rdkafka
161
193
  # This method ensures that each topic has its own mutex,
162
194
  # allowing operations on different topics to proceed in parallel.
163
195
  #
164
- # @param topic [String] Kafka topic name
196
+ # @param topic_name [String] Kafka topic name
165
197
  # @return [Mutex] Mutex for the specified topic
166
198
  #
167
199
  # @note We use a separate mutex (@mutex_for_hash) to protect the creation
168
200
  # of new topic mutexes. This pattern allows fine-grained locking while
169
201
  # maintaining thread-safety.
170
- def mutex_for(topic)
171
- mutex = @mutex_hash[topic]
202
+ def mutex_for(topic_name)
203
+ mutex = @mutex_hash[topic_name]
172
204
 
173
205
  return mutex if mutex
174
206
 
175
207
  # Use a separate mutex to protect the creation of new topic mutexes
176
208
  @mutex_for_hash.synchronize do
177
209
  # Check again in case another thread created it
178
- @mutex_hash[topic] ||= Mutex.new
210
+ @mutex_hash[topic_name] ||= Mutex.new
179
211
  end
180
212
 
181
- @mutex_hash[topic]
213
+ @mutex_hash[topic_name]
182
214
  end
183
215
 
184
216
  # Update the timestamp without acquiring the mutex
@@ -186,13 +218,13 @@ module Rdkafka
186
218
  # This is an optimization that allows refreshing the TTL of existing entries
187
219
  # without the overhead of mutex acquisition.
188
220
  #
189
- # @param topic [String] Kafka topic name
221
+ # @param topic_name [String] Kafka topic name
190
222
  #
191
223
  # @note This method is safe for refreshing existing data regardless of count
192
224
  # because it only updates the timestamp, which doesn't affect the correctness
193
225
  # of concurrent operations.
194
- def refresh_timestamp(topic)
195
- current_info = @counts[topic]
226
+ def refresh_timestamp(topic_name)
227
+ current_info = @counts[topic_name]
196
228
 
197
229
  return unless current_info
198
230
 
@@ -200,6 +232,57 @@ module Rdkafka
200
232
  current_info[0] = monotonic_now_ms
201
233
  end
202
234
 
235
+ # Refreshes an expired entry. While the refresh runs, other callers see a fresh entry and get
236
+ # the known count instead of running their own metadata request.
237
+ #
238
+ # The block performs an authoritative metadata read. We hand it to `set`, which adopts a
239
+ # higher count always and a lower count once the entry has expired (e.g. the topic was
240
+ # recreated with fewer partitions). We then return whatever `set` settled on so a concurrent
241
+ # refresh that wrote a higher value still wins.
242
+ #
243
+ # @param topic_name [String] Kafka topic name
244
+ # @param current_info [Array<Integer>] cached timestamp and partition count
245
+ # @yieldreturn [Integer] Current partition count retrieved from Kafka
246
+ # @return [Integer] Partition count for the topic
247
+ # @raise [RdkafkaError] when the refresh fails on a non-transient error
248
+ def refresh(topic_name, current_info)
249
+ expired_at = current_info[0]
250
+ # Outlasts a failed refresh even with a TTL shorter than one metadata request
251
+ lease = monotonic_now_ms + Defaults::PARTITIONS_COUNT_CACHE_STALE_BACKOFF_MS
252
+ current_info[0] = lease
253
+
254
+ begin
255
+ new_count = yield
256
+ rescue RdkafkaError => e
257
+ raise unless STALE_FALLBACK_ERRORS.include?(e.code)
258
+ ensure
259
+ # Hand the entry back as expired, unless a concurrent write refreshed it meanwhile
260
+ current_info[0] = expired_at if current_info[0] == lease
261
+ end
262
+
263
+ if new_count.nil?
264
+ # A concurrent write refreshed the entry, so it keeps its full TTL
265
+ return current_info[1] unless current_info[0] == expired_at
266
+
267
+ return serve_stale(current_info)
268
+ end
269
+
270
+ set(topic_name, new_count)
271
+
272
+ @counts[topic_name][1]
273
+ end
274
+
275
+ # Keeps serving an expired entry after a failed refresh and schedules the next refresh
276
+ # after {Defaults::PARTITIONS_COUNT_CACHE_STALE_BACKOFF_MS}
277
+ #
278
+ # @param current_info [Array<Integer>] cached timestamp and partition count
279
+ # @return [Integer] last known partition count
280
+ def serve_stale(current_info)
281
+ current_info[0] = monotonic_now_ms - @ttl_ms + Defaults::PARTITIONS_COUNT_CACHE_STALE_BACKOFF_MS
282
+
283
+ current_info[1]
284
+ end
285
+
203
286
  # Check if a timestamp has expired based on the TTL
204
287
  #
205
288
  # @param timestamp [Integer] Monotonic timestamp in milliseconds to check
@@ -33,6 +33,27 @@ module Rdkafka
33
33
 
34
34
  private_constant :EMPTY_HASH
35
35
 
36
+ # FFI types of the `rd_kafka_producev` varargs are resolved once. A variadic call resolves each
37
+ # type argument it gets on every call, unless it already is an `FFI::Type`
38
+
39
+ # `:int` vararg type
40
+ FFI_INT = Rdkafka::Bindings.find_type(:int)
41
+ # `:pointer` vararg type
42
+ FFI_POINTER = Rdkafka::Bindings.find_type(:pointer)
43
+ # `:buffer_in` vararg type
44
+ FFI_BUFFER_IN = Rdkafka::Bindings.find_type(:buffer_in)
45
+ # `:size_t` vararg type
46
+ FFI_SIZE_T = Rdkafka::Bindings.find_type(:size_t)
47
+ # `:int32` vararg type
48
+ FFI_INT32 = Rdkafka::Bindings.find_type(:int32)
49
+ # `:int64` vararg type
50
+ FFI_INT64 = Rdkafka::Bindings.find_type(:int64)
51
+ # `:string` vararg type
52
+ FFI_STRING = Rdkafka::Bindings.find_type(:string)
53
+
54
+ private_constant :FFI_INT, :FFI_POINTER, :FFI_BUFFER_IN, :FFI_SIZE_T, :FFI_INT32, :FFI_INT64,
55
+ :FFI_STRING
56
+
36
57
  # Raised when there was a critical issue when invoking rd_kafka_topic_new
37
58
  # This is a temporary solution until https://github.com/karafka/rdkafka-ruby/issues/451 is
38
59
  # resolved and this is normalized in all the places
@@ -55,8 +76,9 @@ module Rdkafka
55
76
  # @param partitioner [String, nil] name of the partitioner we want to use or nil to use
56
77
  # the "consistent_random" default
57
78
  def initialize(native_kafka, partitioner)
58
- @topics_refs_map = {}
59
- @topics_configs = {}
79
+ @topics_refs_map = EMPTY_HASH
80
+ @topics_configs = EMPTY_HASH
81
+ @topics_mutex = Mutex.new
60
82
  @native_kafka = native_kafka
61
83
  @partitioner = partitioner || "consistent_random"
62
84
 
@@ -66,54 +88,27 @@ module Rdkafka
66
88
 
67
89
  # Sets alternative set of configuration details that can be set per topic
68
90
  #
69
- # @param topic [String] The topic name
91
+ # @param topic_name [String] The topic name
70
92
  # @param config [Hash] config we want to use per topic basis
71
93
  # @param config_hash [Integer] hash of the config. We expect it here instead of computing it,
72
94
  # because it is already computed during the retrieval attempt in the `#produce` flow.
73
95
  # @note It is not allowed to re-set the same topic config twice because of the underlying
74
96
  # librdkafka caching
75
- def set_topic_config(topic, config, config_hash)
76
- # Ensure lock on topic reference just in case
97
+ def set_topic_config(topic_name, config, config_hash)
98
+ # `with_inner` only counts this as an operation in progress; it takes no lock. Concurrent
99
+ # first produces to the same topic are serialized by @topics_mutex, so librdkafka gets one
100
+ # `rd_kafka_topic_new` per topic and config.
77
101
  @native_kafka.with_inner do |inner|
78
- @topics_refs_map[topic] ||= {}
79
- @topics_configs[topic] ||= {}
80
-
81
- return if @topics_configs[topic].key?(config_hash)
82
-
83
- # If config is empty, we create an empty reference that will be used with defaults
84
- rd_topic_config = if config.empty?
85
- nil
86
- else
87
- Rdkafka::Bindings.rd_kafka_topic_conf_new.tap do |topic_config|
88
- config.each do |key, value|
89
- error_buffer = FFI::MemoryPointer.new(:char, 256)
90
- result = Rdkafka::Bindings.rd_kafka_topic_conf_set(
91
- topic_config,
92
- key.to_s,
93
- value.to_s,
94
- error_buffer,
95
- 256
96
- )
97
-
98
- unless result == :config_ok
99
- # rd_kafka_topic_new has not been called yet, so librdkafka has not taken
100
- # ownership of the config. Destroy it ourselves before raising, otherwise the
101
- # partially built rd_kafka_topic_conf_t leaks. (On the rd_kafka_topic_new path
102
- # librdkafka frees the conf on both success and failure, so we never destroy it
103
- # there.)
104
- Rdkafka::Bindings.rd_kafka_topic_conf_destroy(topic_config)
105
- raise Config::ConfigError.new(error_buffer.read_string)
106
- end
107
- end
108
- end
102
+ @topics_mutex.synchronize do
103
+ return if @topics_refs_map.dig(topic_name, config_hash)
104
+
105
+ store_topic_handle(
106
+ topic_name,
107
+ config,
108
+ config_hash,
109
+ create_topic_handle(inner, topic_name, config)
110
+ )
109
111
  end
110
-
111
- topic_handle = Bindings.rd_kafka_topic_new(inner, topic, rd_topic_config)
112
-
113
- raise TopicHandleCreationError.new("Error creating topic handle for topic #{topic}") if topic_handle.null?
114
-
115
- @topics_configs[topic][config_hash] = config
116
- @topics_refs_map[topic][config_hash] = topic_handle
117
112
  end
118
113
  end
119
114
 
@@ -254,14 +249,16 @@ module Rdkafka
254
249
  @native_kafka.close do
255
250
  # We need to remove the topics references objects before we destroy the producer,
256
251
  # otherwise they would leak out
257
- @topics_refs_map.each_value do |refs|
258
- refs.each_value do |ref|
259
- Rdkafka::Bindings.rd_kafka_topic_destroy(ref)
252
+ @topics_mutex.synchronize do
253
+ @topics_refs_map.each_value do |refs|
254
+ refs.each_value do |ref|
255
+ Rdkafka::Bindings.rd_kafka_topic_destroy(ref)
256
+ end
260
257
  end
258
+
259
+ @topics_refs_map = EMPTY_HASH
261
260
  end
262
261
  end
263
-
264
- @topics_refs_map.clear
265
262
  end
266
263
 
267
264
  # Whether this producer has closed
@@ -346,7 +343,7 @@ module Rdkafka
346
343
 
347
344
  # Polls for events in a non-blocking loop, yielding the count after each iteration.
348
345
  #
349
- # This method processes delivery callbacks in a single GVL/mutex session, which is more
346
+ # This method processes delivery callbacks in a single GVL session, which is more
350
347
  # efficient than repeated individual polls. It uses non-blocking polls internally
351
348
  # (no GVL release between polls).
352
349
  #
@@ -367,8 +364,9 @@ module Rdkafka
367
364
  # producer.events_poll_nb_each do |_count|
368
365
  # :stop if monotonic_now >= deadline
369
366
  # end
370
- # @note This method holds the inner lock until the queue is empty or `:stop` is returned.
371
- # Other producer operations (produce, close, etc.) will wait until this method returns.
367
+ # @note This method runs inside `with_inner` and does not hold a lock while it loops.
368
+ # Other operations on the same client run concurrently. `close` waits until this
369
+ # method returns.
372
370
  # @note This method is thread-safe as it uses @native_kafka.with_inner synchronization
373
371
  def events_poll_nb_each
374
372
  closed_producer_check(__method__)
@@ -384,7 +382,7 @@ module Rdkafka
384
382
 
385
383
  # Partition count for a given topic.
386
384
  #
387
- # @param topic [String] The topic name.
385
+ # @param topic_name [String] The topic name.
388
386
  # @return [Integer] partition count for a given topic or `RD_KAFKA_PARTITION_UA (-1)` if it
389
387
  # could not be obtained.
390
388
  #
@@ -399,14 +397,14 @@ module Rdkafka
399
397
  # `RD_KAFKA_PARTITION_UA` too, so producing to a not-yet-created topic does not run a
400
398
  # blocking metadata query on every message; the cache TTL still bounds how long that result
401
399
  # is reused before we look again.
402
- def partition_count(topic)
400
+ def partition_count(topic_name)
403
401
  closed_producer_check(__method__)
404
402
 
405
- self.class.partitions_count_cache.get(topic) do
403
+ self.class.partitions_count_cache.get(topic_name) do
406
404
  topic_metadata = nil
407
405
 
408
406
  @native_kafka.with_inner do |inner|
409
- topic_metadata = ::Rdkafka::Metadata.new(inner, topic).topics&.first
407
+ topic_metadata = ::Rdkafka::Metadata.new(inner, topic_name).topics&.first
410
408
  end
411
409
 
412
410
  topic_metadata ? topic_metadata[:partition_count] : Rdkafka::Bindings::RD_KAFKA_PARTITION_UA
@@ -486,6 +484,9 @@ module Rdkafka
486
484
  set_topic_config(topic, topic_config, topic_config_hash) unless @topics_refs_map.dig(topic, topic_config_hash)
487
485
  topic_ref = @topics_refs_map.dig(topic, topic_config_hash)
488
486
 
487
+ # A NULL topic handle would crash librdkafka, so never hand one over
488
+ raise TopicHandleCreationError.new("No topic handle for topic #{topic}") if topic_ref.nil?
489
+
489
490
  if partition_key
490
491
  partition_count = partition_count(topic)
491
492
 
@@ -535,13 +536,15 @@ module Rdkafka
535
536
  # would orphan the handle in the registry forever, since nothing else will ever resolve it.
536
537
  begin
537
538
  args = [
538
- :int, Rdkafka::Bindings::RD_KAFKA_VTYPE_RKT, :pointer, topic_ref,
539
- :int, Rdkafka::Bindings::RD_KAFKA_VTYPE_MSGFLAGS, :int, Rdkafka::Bindings::RD_KAFKA_MSG_F_COPY,
540
- :int, Rdkafka::Bindings::RD_KAFKA_VTYPE_VALUE, :buffer_in, payload, :size_t, payload_size,
541
- :int, Rdkafka::Bindings::RD_KAFKA_VTYPE_KEY, :buffer_in, key, :size_t, key_size,
542
- :int, Rdkafka::Bindings::RD_KAFKA_VTYPE_PARTITION, :int32, partition,
543
- :int, Rdkafka::Bindings::RD_KAFKA_VTYPE_TIMESTAMP, :int64, raw_timestamp,
544
- :int, Rdkafka::Bindings::RD_KAFKA_VTYPE_OPAQUE, :pointer, delivery_handle
539
+ FFI_INT, Rdkafka::Bindings::RD_KAFKA_VTYPE_RKT, FFI_POINTER, topic_ref,
540
+ FFI_INT, Rdkafka::Bindings::RD_KAFKA_VTYPE_MSGFLAGS,
541
+ FFI_INT, Rdkafka::Bindings::RD_KAFKA_MSG_F_COPY,
542
+ FFI_INT, Rdkafka::Bindings::RD_KAFKA_VTYPE_VALUE,
543
+ FFI_BUFFER_IN, payload, FFI_SIZE_T, payload_size,
544
+ FFI_INT, Rdkafka::Bindings::RD_KAFKA_VTYPE_KEY, FFI_BUFFER_IN, key, FFI_SIZE_T, key_size,
545
+ FFI_INT, Rdkafka::Bindings::RD_KAFKA_VTYPE_PARTITION, FFI_INT32, partition,
546
+ FFI_INT, Rdkafka::Bindings::RD_KAFKA_VTYPE_TIMESTAMP, FFI_INT64, raw_timestamp,
547
+ FFI_INT, Rdkafka::Bindings::RD_KAFKA_VTYPE_OPAQUE, FFI_POINTER, delivery_handle
545
548
  ]
546
549
 
547
550
  if headers && !headers.empty?
@@ -553,26 +556,26 @@ module Rdkafka
553
556
  value0.each do |v|
554
557
  value = v.to_s
555
558
  args.push(
556
- :int, Rdkafka::Bindings::RD_KAFKA_VTYPE_HEADER,
557
- :string, key,
558
- :pointer, value,
559
- :size_t, value.bytesize
559
+ FFI_INT, Rdkafka::Bindings::RD_KAFKA_VTYPE_HEADER,
560
+ FFI_STRING, key,
561
+ FFI_POINTER, value,
562
+ FFI_SIZE_T, value.bytesize
560
563
  )
561
564
  end
562
565
  else
563
566
  # Handle single value
564
567
  value = value0.to_s
565
568
  args.push(
566
- :int, Rdkafka::Bindings::RD_KAFKA_VTYPE_HEADER,
567
- :string, key,
568
- :pointer, value,
569
- :size_t, value.bytesize
569
+ FFI_INT, Rdkafka::Bindings::RD_KAFKA_VTYPE_HEADER,
570
+ FFI_STRING, key,
571
+ FFI_POINTER, value,
572
+ FFI_SIZE_T, value.bytesize
570
573
  )
571
574
  end
572
575
  end
573
576
  end
574
577
 
575
- args.push(:int, Rdkafka::Bindings::RD_KAFKA_VTYPE_END)
578
+ args.push(FFI_INT, Rdkafka::Bindings::RD_KAFKA_VTYPE_END)
576
579
 
577
580
  # Produce the message
578
581
  response = @native_kafka.with_inner do |inner|
@@ -636,5 +639,72 @@ module Rdkafka
636
639
  raise Rdkafka::ClosedProducerError.new(method) if closed?
637
640
  end
638
641
  alias_method :closed_check, :closed_producer_check
642
+
643
+ # Creates a librdkafka topic handle. Callers hold @topics_mutex.
644
+ #
645
+ # @param inner [FFI::Pointer] native producer handle
646
+ # @param topic_name [String] topic name
647
+ # @param config [Hash] per-topic config; empty for the defaults
648
+ # @return [FFI::Pointer] the topic handle
649
+ # @raise [Config::ConfigError] when a per-topic config value is rejected
650
+ # @raise [TopicHandleCreationError] when librdkafka cannot create the handle
651
+ def create_topic_handle(inner, topic_name, config)
652
+ # If config is empty, we create an empty reference that will be used with defaults
653
+ rd_topic_config = if config.empty?
654
+ nil
655
+ else
656
+ Rdkafka::Bindings.rd_kafka_topic_conf_new.tap do |topic_config|
657
+ config.each do |key, value|
658
+ error_buffer = FFI::MemoryPointer.new(:char, 256)
659
+ result = Rdkafka::Bindings.rd_kafka_topic_conf_set(
660
+ topic_config,
661
+ key.to_s,
662
+ value.to_s,
663
+ error_buffer,
664
+ 256
665
+ )
666
+
667
+ unless result == :config_ok
668
+ # rd_kafka_topic_new has not been called yet, so librdkafka has not taken
669
+ # ownership of the config. Destroy it ourselves before raising, otherwise the
670
+ # partially built rd_kafka_topic_conf_t leaks. (On the rd_kafka_topic_new path
671
+ # librdkafka frees the conf on both success and failure, so we never destroy it
672
+ # there.)
673
+ Rdkafka::Bindings.rd_kafka_topic_conf_destroy(topic_config)
674
+ raise Config::ConfigError.new(error_buffer.read_string)
675
+ end
676
+ end
677
+ end
678
+ end
679
+
680
+ topic_handle = Bindings.rd_kafka_topic_new(inner, topic_name, rd_topic_config)
681
+
682
+ raise TopicHandleCreationError.new("Error creating topic handle for topic #{topic_name}") if topic_handle.null?
683
+
684
+ topic_handle
685
+ end
686
+
687
+ # Publishes a topic handle and its config. Callers hold @topics_mutex. Both maps are
688
+ # replaced with frozen copies rather than mutated, and the config goes in first, so a
689
+ # lock-free reader that finds the handle also finds its config.
690
+ #
691
+ # @param topic_name [String] topic name
692
+ # @param config [Hash] per-topic config
693
+ # @param config_hash [Integer] hash of the config
694
+ # @param topic_handle [FFI::Pointer] the topic handle
695
+ def store_topic_handle(topic_name, config, config_hash, topic_handle)
696
+ @topics_configs = with_topic_entry(@topics_configs, topic_name, config_hash, config)
697
+ @topics_refs_map = with_topic_entry(@topics_refs_map, topic_name, config_hash, topic_handle)
698
+ end
699
+
700
+ # @param map [Hash{String => Hash{Integer => Object}}] a frozen topic map
701
+ # @param topic_name [String] topic name
702
+ # @param config_hash [Integer] hash of the config
703
+ # @param value [Object] value to store
704
+ # @return [Hash{String => Hash{Integer => Object}}] a frozen copy of the map with the value
705
+ def with_topic_entry(map, topic_name, config_hash, value)
706
+ entries = map.fetch(topic_name, EMPTY_HASH).merge(config_hash => value).freeze
707
+ map.merge(topic_name => entries).freeze
708
+ end
639
709
  end
640
710
  end