rdkafka 0.22.2 → 0.29.0

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 (120) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +101 -3
  3. data/README.md +21 -14
  4. data/Rakefile +21 -21
  5. data/bin/verify_kafka_warnings +39 -0
  6. data/dist/{librdkafka-2.8.0.tar.gz → librdkafka-2.14.2.tar.gz} +0 -0
  7. data/docker-compose-ssl.yml +35 -0
  8. data/docker-compose.yml +2 -2
  9. data/ext/Rakefile +27 -27
  10. data/lib/rdkafka/abstract_handle.rb +54 -7
  11. data/lib/rdkafka/admin/acl_binding_result.rb +5 -5
  12. data/lib/rdkafka/admin/config_binding_result.rb +2 -2
  13. data/lib/rdkafka/admin/config_resource_binding_result.rb +1 -0
  14. data/lib/rdkafka/admin/create_acl_handle.rb +10 -6
  15. data/lib/rdkafka/admin/create_acl_report.rb +3 -2
  16. data/lib/rdkafka/admin/create_partitions_handle.rb +8 -7
  17. data/lib/rdkafka/admin/create_partitions_report.rb +1 -0
  18. data/lib/rdkafka/admin/create_topic_handle.rb +8 -7
  19. data/lib/rdkafka/admin/create_topic_report.rb +3 -0
  20. data/lib/rdkafka/admin/delete_acl_handle.rb +9 -8
  21. data/lib/rdkafka/admin/delete_acl_report.rb +5 -3
  22. data/lib/rdkafka/admin/delete_groups_handle.rb +10 -7
  23. data/lib/rdkafka/admin/delete_groups_report.rb +3 -0
  24. data/lib/rdkafka/admin/delete_topic_handle.rb +8 -7
  25. data/lib/rdkafka/admin/delete_topic_report.rb +3 -0
  26. data/lib/rdkafka/admin/describe_acl_handle.rb +9 -8
  27. data/lib/rdkafka/admin/describe_acl_report.rb +5 -3
  28. data/lib/rdkafka/admin/describe_configs_handle.rb +7 -10
  29. data/lib/rdkafka/admin/describe_configs_report.rb +8 -6
  30. data/lib/rdkafka/admin/incremental_alter_configs_handle.rb +7 -10
  31. data/lib/rdkafka/admin/incremental_alter_configs_report.rb +8 -6
  32. data/lib/rdkafka/admin/list_offsets_handle.rb +31 -0
  33. data/lib/rdkafka/admin/list_offsets_report.rb +51 -0
  34. data/lib/rdkafka/admin.rb +357 -159
  35. data/lib/rdkafka/bindings.rb +205 -111
  36. data/lib/rdkafka/callbacks/base_handler.rb +62 -0
  37. data/lib/rdkafka/callbacks/create_acl_handler.rb +37 -0
  38. data/lib/rdkafka/callbacks/create_partitions_handler.rb +37 -0
  39. data/lib/rdkafka/callbacks/create_topic_handler.rb +37 -0
  40. data/lib/rdkafka/callbacks/delete_acl_handler.rb +42 -0
  41. data/lib/rdkafka/callbacks/delete_groups_handler.rb +37 -0
  42. data/lib/rdkafka/callbacks/delete_topic_handler.rb +37 -0
  43. data/lib/rdkafka/callbacks/describe_acl_handler.rb +35 -0
  44. data/lib/rdkafka/callbacks/describe_configs_handler.rb +42 -0
  45. data/lib/rdkafka/callbacks/incremental_alter_configs_handler.rb +42 -0
  46. data/lib/rdkafka/callbacks/list_offsets_handler.rb +42 -0
  47. data/lib/rdkafka/callbacks.rb +125 -216
  48. data/lib/rdkafka/config.rb +126 -66
  49. data/lib/rdkafka/consumer/headers.rb +22 -7
  50. data/lib/rdkafka/consumer/message.rb +12 -11
  51. data/lib/rdkafka/consumer/partition.rb +15 -4
  52. data/lib/rdkafka/consumer/topic_partition_list.rb +64 -38
  53. data/lib/rdkafka/consumer.rb +513 -59
  54. data/lib/rdkafka/defaults.rb +125 -0
  55. data/lib/rdkafka/error.rb +40 -14
  56. data/lib/rdkafka/helpers/metadata.rb +29 -0
  57. data/lib/rdkafka/helpers/oauth.rb +45 -13
  58. data/lib/rdkafka/helpers/time.rb +5 -0
  59. data/lib/rdkafka/metadata.rb +128 -37
  60. data/lib/rdkafka/native_kafka.rb +91 -8
  61. data/lib/rdkafka/producer/delivery_handle.rb +6 -6
  62. data/lib/rdkafka/producer/delivery_report.rb +10 -6
  63. data/lib/rdkafka/producer/partitions_count_cache.rb +51 -55
  64. data/lib/rdkafka/producer.rb +193 -95
  65. data/lib/rdkafka/version.rb +6 -3
  66. data/lib/rdkafka.rb +15 -0
  67. data/package-lock.json +331 -0
  68. data/package.json +9 -0
  69. data/rdkafka.gemspec +58 -36
  70. data/renovate.json +39 -23
  71. metadata +39 -125
  72. data/.github/CODEOWNERS +0 -3
  73. data/.github/FUNDING.yml +0 -1
  74. data/.github/workflows/ci_linux_x86_64_gnu.yml +0 -271
  75. data/.github/workflows/ci_linux_x86_64_musl.yml +0 -194
  76. data/.github/workflows/ci_macos_arm64.yml +0 -284
  77. data/.github/workflows/push_linux_x86_64_gnu.yml +0 -65
  78. data/.github/workflows/push_linux_x86_64_musl.yml +0 -79
  79. data/.github/workflows/push_macos_arm64.yml +0 -54
  80. data/.github/workflows/push_ruby.yml +0 -37
  81. data/.github/workflows/verify-action-pins.yml +0 -16
  82. data/.gitignore +0 -14
  83. data/.rspec +0 -2
  84. data/.ruby-gemset +0 -1
  85. data/.ruby-version +0 -1
  86. data/.yardopts +0 -2
  87. data/Gemfile +0 -5
  88. data/ext/README.md +0 -19
  89. data/ext/build_common.sh +0 -361
  90. data/ext/build_linux_x86_64_gnu.sh +0 -306
  91. data/ext/build_linux_x86_64_musl.sh +0 -763
  92. data/ext/build_macos_arm64.sh +0 -550
  93. data/spec/rdkafka/abstract_handle_spec.rb +0 -117
  94. data/spec/rdkafka/admin/create_acl_handle_spec.rb +0 -56
  95. data/spec/rdkafka/admin/create_acl_report_spec.rb +0 -18
  96. data/spec/rdkafka/admin/create_topic_handle_spec.rb +0 -52
  97. data/spec/rdkafka/admin/create_topic_report_spec.rb +0 -16
  98. data/spec/rdkafka/admin/delete_acl_handle_spec.rb +0 -85
  99. data/spec/rdkafka/admin/delete_acl_report_spec.rb +0 -72
  100. data/spec/rdkafka/admin/delete_topic_handle_spec.rb +0 -52
  101. data/spec/rdkafka/admin/delete_topic_report_spec.rb +0 -16
  102. data/spec/rdkafka/admin/describe_acl_handle_spec.rb +0 -85
  103. data/spec/rdkafka/admin/describe_acl_report_spec.rb +0 -73
  104. data/spec/rdkafka/admin_spec.rb +0 -971
  105. data/spec/rdkafka/bindings_spec.rb +0 -199
  106. data/spec/rdkafka/callbacks_spec.rb +0 -20
  107. data/spec/rdkafka/config_spec.rb +0 -258
  108. data/spec/rdkafka/consumer/headers_spec.rb +0 -73
  109. data/spec/rdkafka/consumer/message_spec.rb +0 -139
  110. data/spec/rdkafka/consumer/partition_spec.rb +0 -57
  111. data/spec/rdkafka/consumer/topic_partition_list_spec.rb +0 -248
  112. data/spec/rdkafka/consumer_spec.rb +0 -1274
  113. data/spec/rdkafka/error_spec.rb +0 -89
  114. data/spec/rdkafka/metadata_spec.rb +0 -79
  115. data/spec/rdkafka/native_kafka_spec.rb +0 -130
  116. data/spec/rdkafka/producer/delivery_handle_spec.rb +0 -45
  117. data/spec/rdkafka/producer/delivery_report_spec.rb +0 -25
  118. data/spec/rdkafka/producer/partitions_count_cache_spec.rb +0 -359
  119. data/spec/rdkafka/producer_spec.rb +0 -1345
  120. data/spec/spec_helper.rb +0 -195
@@ -14,10 +14,45 @@ module Rdkafka
14
14
  include Enumerable
15
15
  include Helpers::Time
16
16
  include Helpers::OAuth
17
+ include Helpers::Metadata
17
18
 
18
19
  # @private
20
+ # @param native_kafka [NativeKafka] wrapper around the native Kafka consumer handle
19
21
  def initialize(native_kafka)
20
22
  @native_kafka = native_kafka
23
+ # Single-element holder shared with the GC finalizer so it can destroy the lazily created
24
+ # consumer queue without capturing `self` (capturing the consumer in its own finalizer would
25
+ # pin it and prevent it from ever being collected).
26
+ @consumer_queue_holder = []
27
+
28
+ # Makes sure the consumer is closed (consumer queue destroyed and native client destroyed)
29
+ # before it gets GCed by Ruby.
30
+ ObjectSpace.define_finalizer(self, self.class.finalizer(native_kafka, @consumer_queue_holder))
31
+ end
32
+
33
+ # Builds the GC finalizer for a consumer. It mirrors {#close}: close the consumer, destroy the
34
+ # consumer-queue reference, then destroy the native client. The default `NativeKafka#finalizer`
35
+ # went straight to `rd_kafka_destroy`, leaving the consumer-queue reference (from
36
+ # `rd_kafka_queue_get_consumer`, taken by `poll_batch`) dangling - which can make
37
+ # `rd_kafka_destroy` block inside the finalizer (process hang at GC/shutdown) or leak the handle.
38
+ #
39
+ # @private
40
+ # @param native_kafka [NativeKafka] the wrapped native client
41
+ # @param queue_holder [Array] single-element holder carrying the consumer queue pointer (or empty)
42
+ # @return [Proc] finalizer proc that must not reference the consumer instance
43
+ def self.finalizer(native_kafka, queue_holder)
44
+ proc do
45
+ next if native_kafka.closed?
46
+
47
+ native_kafka.synchronize do |inner|
48
+ Rdkafka::Bindings.rd_kafka_consumer_close(inner)
49
+
50
+ queue = queue_holder[0]
51
+ Rdkafka::Bindings.rd_kafka_queue_destroy(queue) if queue
52
+ end
53
+
54
+ native_kafka.close
55
+ end
21
56
  end
22
57
 
23
58
  # Starts the native Kafka polling thread and kicks off the init polling
@@ -33,8 +68,142 @@ module Rdkafka
33
68
  end
34
69
  end
35
70
 
36
- def finalizer
37
- ->(_) { close }
71
+ # Enable IO event notifications for fiber scheduler integration
72
+ # When the consumer queue has messages, librdkafka will write to your FD
73
+ #
74
+ # @param fd [Integer] file descriptor to signal (from IO.pipe or eventfd)
75
+ # @param payload [String] data to write to fd (default: "\x01")
76
+ # @return [nil]
77
+ # @raise [ClosedInnerError] when the consumer is closed
78
+ #
79
+ # @example Using with fiber scheduler
80
+ # consumer = config.consumer
81
+ # consumer.subscribe("topic")
82
+ #
83
+ # # Create notification FD
84
+ # signal_r, signal_w = IO.pipe
85
+ #
86
+ # # Enable librdkafka to signal when messages arrive
87
+ # consumer.enable_queue_io_events(signal_w.fileno)
88
+ #
89
+ # # Monitor with select/poll
90
+ # loop do
91
+ # readable, = IO.select([signal_r], nil, nil, timeout)
92
+ # if readable
93
+ # signal_r.read_nonblock(1024) rescue nil # Drain signal
94
+ # while msg = consumer.poll(0)
95
+ # process(msg)
96
+ # end
97
+ # end
98
+ # end
99
+ def enable_queue_io_events(fd, payload = "\x01")
100
+ @native_kafka.enable_main_queue_io_events(fd, payload)
101
+ end
102
+
103
+ # Enable IO event notifications for background events
104
+ # @param fd [Integer] file descriptor to signal (from IO.pipe or eventfd)
105
+ # @param payload [String] data to write to fd (default: "\x01")
106
+ # @return [nil]
107
+ # @raise [ClosedInnerError] when the consumer is closed
108
+ def enable_background_queue_io_events(fd, payload = "\x01")
109
+ @native_kafka.enable_background_queue_io_events(fd, payload)
110
+ end
111
+
112
+ # Polls for events in a non-blocking loop, yielding the count after each iteration.
113
+ #
114
+ # This method processes events (stats, errors, etc.) in a single GVL/mutex session,
115
+ # which is more efficient than repeated individual polls. It uses non-blocking polls
116
+ # internally (no GVL release between polls).
117
+ #
118
+ # Yields the count of events processed after each poll iteration, allowing the caller
119
+ # to implement timeout or other termination logic by returning `:stop`.
120
+ #
121
+ # @yield [count] Called after each poll iteration
122
+ # @yieldparam count [Integer] Number of events processed in this iteration
123
+ # @yieldreturn [Symbol, Object] Return `:stop` to break the loop, any other value continues
124
+ # @return [nil]
125
+ # @raise [Rdkafka::ClosedConsumerError] if called on a closed consumer
126
+ #
127
+ # @note This method holds the inner lock until the queue is empty or `:stop` is returned.
128
+ # Other consumer operations will wait until this method returns.
129
+ # @note This method is thread-safe as it uses @native_kafka.with_inner synchronization
130
+ # @note Do NOT use this if `consumer_poll_set` was set to `true`
131
+ #
132
+ # @example Drain all pending events
133
+ # consumer.events_poll_nb_each { |_count| }
134
+ #
135
+ # @example With timeout control
136
+ # deadline = monotonic_now + timeout_ms
137
+ # consumer.events_poll_nb_each do |_count|
138
+ # :stop if monotonic_now >= deadline
139
+ # end
140
+ def events_poll_nb_each
141
+ closed_consumer_check(__method__)
142
+
143
+ @native_kafka.with_inner do |inner|
144
+ loop do
145
+ count = Rdkafka::Bindings.rd_kafka_poll_nb(inner, 0)
146
+ break if count.zero?
147
+ break if yield(count) == :stop
148
+ end
149
+ end
150
+ end
151
+
152
+ # Polls for messages in a non-blocking loop, yielding each message to the caller.
153
+ #
154
+ # This method processes messages in a single GVL/mutex session until the queue is empty
155
+ # or the caller returns `:stop`. It handles the message pointer lifecycle internally,
156
+ # ensuring proper cleanup via `rd_kafka_message_destroy`.
157
+ #
158
+ # @yield [message] Called for each message received
159
+ # @yieldparam message [Consumer::Message] The received message
160
+ # @yieldreturn [Symbol, Object] Return `:stop` to break the loop, any other value continues
161
+ # @return [nil]
162
+ # @raise [Rdkafka::ClosedConsumerError] if called on a closed consumer
163
+ # @raise [Rdkafka::RdkafkaError] if a Kafka error occurs while polling
164
+ #
165
+ # @note This method uses `rd_kafka_consumer_poll` to fetch messages, unlike
166
+ # `events_poll_nb_each` which uses `rd_kafka_poll` for event callbacks (delivery reports,
167
+ # statistics, etc.). For consumers, use this method to receive messages and
168
+ # `events_poll_nb_each` for processing background events.
169
+ # @note This method holds the inner lock for the duration. Other consumer operations
170
+ # will wait until this method returns.
171
+ # @note Timeout/max_messages logic should be implemented by the caller
172
+ #
173
+ # @example Process messages until queue is empty
174
+ # consumer.poll_nb_each do |message|
175
+ # process(message)
176
+ # end
177
+ #
178
+ # @example Process with early termination
179
+ # count = 0
180
+ # consumer.poll_nb_each do |message|
181
+ # process(message)
182
+ # count += 1
183
+ # :stop if count >= 10
184
+ # end
185
+ def poll_nb_each
186
+ closed_consumer_check(__method__)
187
+
188
+ @native_kafka.with_inner do |inner|
189
+ loop do
190
+ message_ptr = Rdkafka::Bindings.rd_kafka_consumer_poll_nb(inner, 0)
191
+ break if message_ptr.null?
192
+
193
+ begin
194
+ native_message = Rdkafka::Bindings::Message.new(message_ptr)
195
+
196
+ if native_message[:err] != Rdkafka::Bindings::RD_KAFKA_RESP_ERR_NO_ERROR
197
+ raise Rdkafka::RdkafkaError.new(native_message[:err])
198
+ end
199
+
200
+ result = yield Consumer::Message.new(native_message)
201
+ break if result == :stop
202
+ ensure
203
+ Rdkafka::Bindings.rd_kafka_message_destroy(message_ptr)
204
+ end
205
+ end
206
+ end
38
207
  end
39
208
 
40
209
  # Close this consumer
@@ -45,6 +214,12 @@ module Rdkafka
45
214
 
46
215
  @native_kafka.synchronize do |inner|
47
216
  Rdkafka::Bindings.rd_kafka_consumer_close(inner)
217
+
218
+ if @consumer_queue
219
+ Rdkafka::Bindings.rd_kafka_queue_destroy(@consumer_queue)
220
+ @consumer_queue = nil
221
+ @consumer_queue_holder[0] = nil
222
+ end
48
223
  end
49
224
 
50
225
  @native_kafka.close
@@ -67,15 +242,15 @@ module Rdkafka
67
242
  tpl = Rdkafka::Bindings.rd_kafka_topic_partition_list_new(topics.length)
68
243
 
69
244
  topics.each do |topic|
70
- Rdkafka::Bindings.rd_kafka_topic_partition_list_add(tpl, topic, -1)
245
+ Rdkafka::Bindings.rd_kafka_topic_partition_list_add(tpl, topic, Rdkafka::Bindings::RD_KAFKA_PARTITION_UA)
71
246
  end
72
247
 
73
248
  # Subscribe to topic partition list and check this was successful
74
249
  response = @native_kafka.with_inner do |inner|
75
250
  Rdkafka::Bindings.rd_kafka_subscribe(inner, tpl)
76
251
  end
77
- if response != 0
78
- raise Rdkafka::RdkafkaError.new(response, "Error subscribing to '#{topics.join(', ')}'")
252
+ if response != Rdkafka::Bindings::RD_KAFKA_RESP_ERR_NO_ERROR
253
+ raise Rdkafka::RdkafkaError.new(response, "Error subscribing to '#{topics.join(", ")}'")
79
254
  end
80
255
  ensure
81
256
  Rdkafka::Bindings.rd_kafka_topic_partition_list_destroy(tpl) unless tpl.nil?
@@ -91,7 +266,7 @@ module Rdkafka
91
266
  response = @native_kafka.with_inner do |inner|
92
267
  Rdkafka::Bindings.rd_kafka_unsubscribe(inner)
93
268
  end
94
- if response != 0
269
+ if response != Rdkafka::Bindings::RD_KAFKA_RESP_ERR_NO_ERROR
95
270
  raise Rdkafka::RdkafkaError.new(response)
96
271
  end
97
272
  end
@@ -115,7 +290,7 @@ module Rdkafka
115
290
  Rdkafka::Bindings.rd_kafka_pause_partitions(inner, tpl)
116
291
  end
117
292
 
118
- if response != 0
293
+ if response != Rdkafka::Bindings::RD_KAFKA_RESP_ERR_NO_ERROR
119
294
  list = TopicPartitionList.from_native_tpl(tpl)
120
295
  raise Rdkafka::RdkafkaTopicPartitionListError.new(response, list, "Error pausing '#{list.to_h}'")
121
296
  end
@@ -142,7 +317,7 @@ module Rdkafka
142
317
  response = @native_kafka.with_inner do |inner|
143
318
  Rdkafka::Bindings.rd_kafka_resume_partitions(inner, tpl)
144
319
  end
145
- if response != 0
320
+ if response != Rdkafka::Bindings::RD_KAFKA_RESP_ERR_NO_ERROR
146
321
  raise Rdkafka::RdkafkaError.new(response, "Error resume '#{list.to_h}'")
147
322
  end
148
323
  ensure
@@ -162,7 +337,7 @@ module Rdkafka
162
337
  Rdkafka::Bindings.rd_kafka_subscription(inner, ptr)
163
338
  end
164
339
 
165
- if response != 0
340
+ if response != Rdkafka::Bindings::RD_KAFKA_RESP_ERR_NO_ERROR
166
341
  raise Rdkafka::RdkafkaError.new(response)
167
342
  end
168
343
 
@@ -192,7 +367,7 @@ module Rdkafka
192
367
  response = @native_kafka.with_inner do |inner|
193
368
  Rdkafka::Bindings.rd_kafka_assign(inner, tpl)
194
369
  end
195
- if response != 0
370
+ if response != Rdkafka::Bindings::RD_KAFKA_RESP_ERR_NO_ERROR
196
371
  raise Rdkafka::RdkafkaError.new(response, "Error assigning '#{list.to_h}'")
197
372
  end
198
373
  ensure
@@ -211,7 +386,7 @@ module Rdkafka
211
386
  response = @native_kafka.with_inner do |inner|
212
387
  Rdkafka::Bindings.rd_kafka_assignment(inner, ptr)
213
388
  end
214
- if response != 0
389
+ if response != Rdkafka::Bindings::RD_KAFKA_RESP_ERR_NO_ERROR
215
390
  raise Rdkafka::RdkafkaError.new(response)
216
391
  end
217
392
 
@@ -225,7 +400,7 @@ module Rdkafka
225
400
  end
226
401
  end
227
402
  ensure
228
- ptr.free unless ptr.nil?
403
+ ptr&.free
229
404
  end
230
405
 
231
406
  # @return [Boolean] true if our current assignment has been lost involuntarily.
@@ -246,7 +421,7 @@ module Rdkafka
246
421
  # @param timeout_ms [Integer] The timeout for fetching this information.
247
422
  # @return [TopicPartitionList]
248
423
  # @raise [RdkafkaError] When getting the committed positions fails.
249
- def committed(list=nil, timeout_ms=2000)
424
+ def committed(list = nil, timeout_ms = Defaults::CONSUMER_COMMITTED_TIMEOUT_MS)
250
425
  closed_consumer_check(__method__)
251
426
 
252
427
  if list.nil?
@@ -261,7 +436,7 @@ module Rdkafka
261
436
  response = @native_kafka.with_inner do |inner|
262
437
  Rdkafka::Bindings.rd_kafka_committed(inner, tpl, timeout_ms)
263
438
  end
264
- if response != 0
439
+ if response != Rdkafka::Bindings::RD_KAFKA_RESP_ERR_NO_ERROR
265
440
  raise Rdkafka::RdkafkaError.new(response)
266
441
  end
267
442
  TopicPartitionList.from_native_tpl(tpl)
@@ -278,7 +453,9 @@ module Rdkafka
278
453
  # @return [TopicPartitionList]
279
454
  #
280
455
  # @raise [RdkafkaError] When getting the positions fails.
281
- def position(list=nil)
456
+ def position(list = nil)
457
+ closed_consumer_check(__method__)
458
+
282
459
  if list.nil?
283
460
  list = assignment
284
461
  elsif !list.is_a?(TopicPartitionList)
@@ -291,11 +468,13 @@ module Rdkafka
291
468
  Rdkafka::Bindings.rd_kafka_position(inner, tpl)
292
469
  end
293
470
 
294
- if response != 0
471
+ if response != Rdkafka::Bindings::RD_KAFKA_RESP_ERR_NO_ERROR
295
472
  raise Rdkafka::RdkafkaError.new(response)
296
473
  end
297
474
 
298
475
  TopicPartitionList.from_native_tpl(tpl)
476
+ ensure
477
+ Rdkafka::Bindings.rd_kafka_topic_partition_list_destroy(tpl) if tpl
299
478
  end
300
479
 
301
480
  # Query broker for low (oldest/beginning) and high (newest/end) offsets for a partition.
@@ -305,7 +484,7 @@ module Rdkafka
305
484
  # @param timeout_ms [Integer] The timeout for querying the broker
306
485
  # @return [Integer] The low and high watermark
307
486
  # @raise [RdkafkaError] When querying the broker fails.
308
- def query_watermark_offsets(topic, partition, timeout_ms=1000)
487
+ def query_watermark_offsets(topic, partition, timeout_ms = Defaults::CONSUMER_QUERY_WATERMARK_TIMEOUT_MS)
309
488
  closed_consumer_check(__method__)
310
489
 
311
490
  low = FFI::MemoryPointer.new(:int64, 1)
@@ -318,17 +497,17 @@ module Rdkafka
318
497
  partition,
319
498
  low,
320
499
  high,
321
- timeout_ms,
500
+ timeout_ms
322
501
  )
323
502
  end
324
- if response != 0
503
+ if response != Rdkafka::Bindings::RD_KAFKA_RESP_ERR_NO_ERROR
325
504
  raise Rdkafka::RdkafkaError.new(response, "Error querying watermark offsets for partition #{partition} of #{topic}")
326
505
  end
327
506
 
328
- return low.read_array_of_int64(1).first, high.read_array_of_int64(1).first
507
+ [low.read_array_of_int64(1).first, high.read_array_of_int64(1).first]
329
508
  ensure
330
- low.free unless low.nil?
331
- high.free unless high.nil?
509
+ low&.free
510
+ high&.free
332
511
  end
333
512
 
334
513
  # Calculate the consumer lag per partition for the provided topic partition list.
@@ -338,19 +517,18 @@ module Rdkafka
338
517
  #
339
518
  # @param topic_partition_list [TopicPartitionList] The list to calculate lag for.
340
519
  # @param watermark_timeout_ms [Integer] The timeout for each query watermark call.
341
- # @return [Hash<String, Hash<Integer, Integer>>] A hash containing all topics with the lag
520
+ # @return [Hash{String => Hash{Integer => Integer}}] A hash containing all topics with the lag
342
521
  # per partition
343
522
  # @raise [RdkafkaError] When querying the broker fails.
344
- def lag(topic_partition_list, watermark_timeout_ms=1000)
523
+ def lag(topic_partition_list, watermark_timeout_ms = Defaults::CONSUMER_LAG_TIMEOUT_MS)
345
524
  out = {}
346
525
 
347
526
  topic_partition_list.to_h.each do |topic, partitions|
348
- # Query high watermarks for this topic's partitions
349
- # and compare to the offset in the list.
527
+ # Query high watermarks for this topic's partitions and compare to the offset in the list.
350
528
  topic_out = {}
351
529
  partitions.each do |p|
352
530
  next if p.offset.nil?
353
- low, high = query_watermark_offsets(
531
+ _low, high = query_watermark_offsets(
354
532
  topic,
355
533
  p.partition,
356
534
  watermark_timeout_ms
@@ -364,11 +542,12 @@ module Rdkafka
364
542
 
365
543
  # Returns the ClusterId as reported in broker metadata.
366
544
  #
545
+ # @param timeout_ms [Integer] timeout in milliseconds to wait for the cluster id
367
546
  # @return [String, nil]
368
- def cluster_id
547
+ def cluster_id(timeout_ms = Defaults::CONSUMER_CLUSTER_ID_TIMEOUT_MS)
369
548
  closed_consumer_check(__method__)
370
549
  @native_kafka.with_inner do |inner|
371
- Rdkafka::Bindings.rd_kafka_clusterid(inner)
550
+ read_and_free_native_string(inner, Rdkafka::Bindings.rd_kafka_clusterid(inner, timeout_ms))
372
551
  end
373
552
  end
374
553
 
@@ -380,7 +559,7 @@ module Rdkafka
380
559
  def member_id
381
560
  closed_consumer_check(__method__)
382
561
  @native_kafka.with_inner do |inner|
383
- Rdkafka::Bindings.rd_kafka_memberid(inner)
562
+ read_and_free_native_string(inner, Rdkafka::Bindings.rd_kafka_memberid(inner))
384
563
  end
385
564
  end
386
565
 
@@ -389,16 +568,34 @@ module Rdkafka
389
568
  # When using this `enable.auto.offset.store` should be set to `false` in the config.
390
569
  #
391
570
  # @param message [Rdkafka::Consumer::Message] The message which offset will be stored
571
+ # @param metadata [String, nil] commit metadata string to store alongside the offset
392
572
  # @return [nil]
393
573
  # @raise [RdkafkaError] When storing the offset fails
394
- def store_offset(message)
574
+ def store_offset(message, metadata = nil)
395
575
  closed_consumer_check(__method__)
396
576
 
397
577
  list = TopicPartitionList.new
398
- list.add_topic_and_partitions_with_offsets(
399
- message.topic,
400
- message.partition => message.offset + 1
401
- )
578
+
579
+ # For metadata aware commits we build the partition reference directly to save on
580
+ # objects allocations
581
+ if metadata
582
+ list.add_topic_and_partitions_with_offsets(
583
+ message.topic,
584
+ [
585
+ Consumer::Partition.new(
586
+ message.partition,
587
+ message.offset + 1,
588
+ 0,
589
+ metadata
590
+ )
591
+ ]
592
+ )
593
+ else
594
+ list.add_topic_and_partitions_with_offsets(
595
+ message.topic,
596
+ message.partition => message.offset + 1
597
+ )
598
+ end
402
599
 
403
600
  tpl = list.to_native_tpl
404
601
 
@@ -409,7 +606,7 @@ module Rdkafka
409
606
  )
410
607
  end
411
608
 
412
- if response != 0
609
+ if response != Rdkafka::Bindings::RD_KAFKA_RESP_ERR_NO_ERROR
413
610
  raise Rdkafka::RdkafkaError.new(response)
414
611
  end
415
612
  ensure
@@ -427,8 +624,7 @@ module Rdkafka
427
624
  end
428
625
 
429
626
  # Seek to a particular message by providing the topic, partition and offset.
430
- # The next poll on the topic/partition will return the
431
- # message at the given offset.
627
+ # The next poll on the topic/partition will return the message at the given offset.
432
628
  #
433
629
  # @param topic [String] The topic in which to seek
434
630
  # @param partition [Integer] The partition number to seek
@@ -451,9 +647,9 @@ module Rdkafka
451
647
  native_topic,
452
648
  partition,
453
649
  offset,
454
- 0 # timeout
650
+ Defaults::CONSUMER_SEEK_TIMEOUT_MS
455
651
  )
456
- if response != 0
652
+ if response != Rdkafka::Bindings::RD_KAFKA_RESP_ERR_NO_ERROR
457
653
  raise Rdkafka::RdkafkaError.new(response)
458
654
  end
459
655
  ensure
@@ -465,11 +661,10 @@ module Rdkafka
465
661
  # Lookup offset for the given partitions by timestamp.
466
662
  #
467
663
  # @param list [TopicPartitionList] The TopicPartitionList with timestamps instead of offsets
468
- #
664
+ # @param timeout_ms [Integer] timeout in milliseconds for the operation
469
665
  # @return [TopicPartitionList]
470
- #
471
666
  # @raise [RdKafkaError] When the OffsetForTimes lookup fails
472
- def offsets_for_times(list, timeout_ms = 1000)
667
+ def offsets_for_times(list, timeout_ms = Defaults::CONSUMER_OFFSETS_FOR_TIMES_TIMEOUT_MS)
473
668
  closed_consumer_check(__method__)
474
669
 
475
670
  if !list.is_a?(TopicPartitionList)
@@ -486,7 +681,7 @@ module Rdkafka
486
681
  )
487
682
  end
488
683
 
489
- if response != 0
684
+ if response != Rdkafka::Bindings::RD_KAFKA_RESP_ERR_NO_ERROR
490
685
  raise Rdkafka::RdkafkaError.new(response)
491
686
  end
492
687
 
@@ -497,8 +692,7 @@ module Rdkafka
497
692
 
498
693
  # Manually commit the current offsets of this consumer.
499
694
  #
500
- # To use this set `enable.auto.commit`to `false` to disable automatic triggering
501
- # of commits.
695
+ # To use this set `enable.auto.commit`to `false` to disable automatic triggering of commits.
502
696
  #
503
697
  # If `enable.auto.offset.store` is set to `true` the offset of the last consumed
504
698
  # message for every partition is used. If set to `false` you can use {store_offset} to
@@ -508,20 +702,20 @@ module Rdkafka
508
702
  # @param async [Boolean] Whether to commit async or wait for the commit to finish
509
703
  # @return [nil]
510
704
  # @raise [RdkafkaError] When committing fails
511
- def commit(list=nil, async=false)
705
+ def commit(list = nil, async = false)
512
706
  closed_consumer_check(__method__)
513
707
 
514
708
  if !list.nil? && !list.is_a?(TopicPartitionList)
515
709
  raise TypeError.new("list has to be nil or a TopicPartitionList")
516
710
  end
517
711
 
518
- tpl = list ? list.to_native_tpl : nil
712
+ tpl = list&.to_native_tpl
519
713
 
520
714
  begin
521
715
  response = @native_kafka.with_inner do |inner|
522
716
  Rdkafka::Bindings.rd_kafka_commit(inner, tpl, async)
523
717
  end
524
- if response != 0
718
+ if response != Rdkafka::Bindings::RD_KAFKA_RESP_ERR_NO_ERROR
525
719
  raise Rdkafka::RdkafkaError.new(response)
526
720
  end
527
721
  ensure
@@ -546,7 +740,48 @@ module Rdkafka
546
740
  # Create struct wrapper
547
741
  native_message = Rdkafka::Bindings::Message.new(message_ptr)
548
742
  # Raise error if needed
549
- if native_message[:err] != 0
743
+ if native_message[:err] != Rdkafka::Bindings::RD_KAFKA_RESP_ERR_NO_ERROR
744
+ raise Rdkafka::RdkafkaError.new(native_message[:err])
745
+ end
746
+ # Create a message to pass out
747
+ Rdkafka::Consumer::Message.new(native_message)
748
+ end
749
+ ensure
750
+ # Clean up rdkafka message if there is one
751
+ if message_ptr && !message_ptr.null?
752
+ Rdkafka::Bindings.rd_kafka_message_destroy(message_ptr)
753
+ end
754
+ end
755
+
756
+ # Poll for the next message without releasing the GVL (Global VM Lock).
757
+ #
758
+ # This is more efficient than regular polling for non-blocking poll(0) calls,
759
+ # particularly useful in fiber scheduler contexts where GVL release/reacquire
760
+ # overhead is wasteful since we don't expect to wait.
761
+ #
762
+ # @param timeout_ms [Integer] Timeout of this poll (default: 0 for non-blocking)
763
+ # @return [Message, nil] A message or nil if there was no new message within the timeout
764
+ # @raise [RdkafkaError] When polling fails
765
+ #
766
+ # @example Using with fiber scheduler
767
+ # # After receiving IO notification that messages are available
768
+ # while msg = consumer.poll_nb
769
+ # process(msg)
770
+ # end
771
+ def poll_nb(timeout_ms = 0)
772
+ closed_consumer_check(__method__)
773
+
774
+ message_ptr = @native_kafka.with_inner do |inner|
775
+ Rdkafka::Bindings.rd_kafka_consumer_poll_nb(inner, timeout_ms)
776
+ end
777
+
778
+ if message_ptr.null?
779
+ nil
780
+ else
781
+ # Create struct wrapper
782
+ native_message = Rdkafka::Bindings::Message.new(message_ptr)
783
+ # Raise error if needed
784
+ if native_message[:err] != Rdkafka::Bindings::RD_KAFKA_RESP_ERR_NO_ERROR
550
785
  raise Rdkafka::RdkafkaError.new(native_message[:err])
551
786
  end
552
787
  # Create a message to pass out
@@ -579,37 +814,212 @@ module Rdkafka
579
814
  # @note This method technically should be called `#poll` and the current `#poll` should be
580
815
  # called `#consumer_poll` though we keep the current naming convention to make it backward
581
816
  # compatible.
582
- def events_poll(timeout_ms = 0)
817
+ def events_poll(timeout_ms = Defaults::CONSUMER_EVENTS_POLL_TIMEOUT_MS)
583
818
  @native_kafka.with_inner do |inner|
584
819
  Rdkafka::Bindings.rd_kafka_poll(inner, timeout_ms)
585
820
  end
586
821
  end
587
822
 
823
+ # Polls the main rdkafka queue without releasing the GVL (Global VM Lock).
824
+ #
825
+ # This is more efficient than regular events_poll for non-blocking poll(0) calls,
826
+ # particularly useful in fiber scheduler contexts where GVL release/reacquire
827
+ # overhead is wasteful since we don't expect to wait.
828
+ #
829
+ # @param timeout_ms [Integer] poll timeout (default: 0 for non-blocking)
830
+ # @return [Integer] the number of events served
831
+ #
832
+ # @see #events_poll for more details on when to use this method
833
+ def events_poll_nb(timeout_ms = 0)
834
+ @native_kafka.with_inner do |inner|
835
+ Rdkafka::Bindings.rd_kafka_poll_nb(inner, timeout_ms)
836
+ end
837
+ end
838
+
839
+ # Poll for a batch of messages from the consumer queue in a single FFI call.
840
+ #
841
+ # This is more efficient than calling {#poll} in a loop because it crosses the FFI
842
+ # boundary only once to fetch up to `max_items` messages.
843
+ #
844
+ # The timeout controls how long to wait for the **first** message. Once any message
845
+ # is available, librdkafka fills the buffer with whatever is immediately ready and
846
+ # returns without further waiting.
847
+ #
848
+ # Error events (e.g. `:partition_eof`) are returned inline as {RdkafkaError} objects
849
+ # rather than raised, so callers receive the complete batch — both messages and errors —
850
+ # and can decide how to handle each. This is particularly useful when multiple partitions
851
+ # signal EOF simultaneously: all signals appear in the returned array rather than only
852
+ # the first one being raised and the rest silently discarded.
853
+ #
854
+ # @param timeout_ms [Integer] Timeout waiting for the first message (-1 for infinite)
855
+ # @param max_items [Integer] Maximum number of messages to return per call
856
+ # @return [Array<Message, RdkafkaError>] Batch of messages and/or error events in arrival order
857
+ # @raise [ClosedConsumerError] When called on a closed consumer
858
+ def poll_batch(timeout_ms, max_items: 100)
859
+ closed_consumer_check(__method__)
860
+
861
+ buffer = batch_buffer(max_items)
862
+ results = []
863
+
864
+ count = @native_kafka.with_inner do |_inner|
865
+ Rdkafka::Bindings.rd_kafka_consume_batch_queue(
866
+ consumer_queue,
867
+ timeout_ms,
868
+ buffer,
869
+ max_items
870
+ )
871
+ end
872
+
873
+ return results if count <= 0
874
+
875
+ i = 0
876
+ begin
877
+ while i < count
878
+ ptr = buffer.get_pointer(i * FFI::Pointer.size)
879
+
880
+ if ptr.null?
881
+ i += 1
882
+ next
883
+ end
884
+
885
+ native_message = Rdkafka::Bindings::Message.new(ptr)
886
+
887
+ if native_message[:err] != Rdkafka::Bindings::RD_KAFKA_RESP_ERR_NO_ERROR
888
+ results << Rdkafka::RdkafkaError.new(native_message[:err])
889
+ Rdkafka::Bindings.rd_kafka_message_destroy(ptr)
890
+ i += 1
891
+ next
892
+ end
893
+
894
+ begin
895
+ results << Rdkafka::Consumer::Message.new(native_message)
896
+ rescue Rdkafka::RdkafkaError => e
897
+ # A message that fails to build (e.g. a header read error) is surfaced inline as an
898
+ # error event rather than discarding the whole batch - including the messages already
899
+ # built - and raising, which silently lost them once their offsets had been stored.
900
+ results << e
901
+ ensure
902
+ Rdkafka::Bindings.rd_kafka_message_destroy(ptr)
903
+ end
904
+
905
+ i += 1
906
+ end
907
+ ensure
908
+ while i < count
909
+ ptr = buffer.get_pointer(i * FFI::Pointer.size)
910
+ Rdkafka::Bindings.rd_kafka_message_destroy(ptr) unless ptr.null?
911
+ i += 1
912
+ end
913
+ end
914
+
915
+ results
916
+ end
917
+
918
+ # Poll for a batch of messages without releasing the GVL (Global VM Lock).
919
+ #
920
+ # This is more efficient than {#poll_batch} for non-blocking poll(0) calls,
921
+ # particularly useful in fiber scheduler contexts where GVL release/reacquire
922
+ # overhead is wasteful since we don't expect to wait.
923
+ #
924
+ # @note Since the GVL is not released, a non-zero timeout_ms will block all Ruby
925
+ # threads/fibers for the duration. Use {#poll_batch} if you need a blocking wait.
926
+ #
927
+ # Error events are returned inline as {RdkafkaError} objects; see {#poll_batch} for details.
928
+ #
929
+ # @param timeout_ms [Integer] Timeout waiting for the first message (default: 0 for non-blocking)
930
+ # @param max_items [Integer] Maximum number of messages to return per call
931
+ # @return [Array<Message, RdkafkaError>] Batch of messages and/or error events in arrival order
932
+ # @raise [ClosedConsumerError] When called on a closed consumer
933
+ def poll_batch_nb(timeout_ms = 0, max_items: 100)
934
+ closed_consumer_check(__method__)
935
+
936
+ buffer = batch_buffer(max_items)
937
+ results = []
938
+
939
+ count = @native_kafka.with_inner do |_inner|
940
+ Rdkafka::Bindings.rd_kafka_consume_batch_queue_nb(
941
+ consumer_queue,
942
+ timeout_ms,
943
+ buffer,
944
+ max_items
945
+ )
946
+ end
947
+
948
+ return results if count <= 0
949
+
950
+ i = 0
951
+ begin
952
+ while i < count
953
+ ptr = buffer.get_pointer(i * FFI::Pointer.size)
954
+
955
+ if ptr.null?
956
+ i += 1
957
+ next
958
+ end
959
+
960
+ native_message = Rdkafka::Bindings::Message.new(ptr)
961
+
962
+ if native_message[:err] != Rdkafka::Bindings::RD_KAFKA_RESP_ERR_NO_ERROR
963
+ results << Rdkafka::RdkafkaError.new(native_message[:err])
964
+ Rdkafka::Bindings.rd_kafka_message_destroy(ptr)
965
+ i += 1
966
+ next
967
+ end
968
+
969
+ begin
970
+ results << Rdkafka::Consumer::Message.new(native_message)
971
+ rescue Rdkafka::RdkafkaError => e
972
+ # A message that fails to build (e.g. a header read error) is surfaced inline as an
973
+ # error event rather than discarding the whole batch - including the messages already
974
+ # built - and raising, which silently lost them once their offsets had been stored.
975
+ results << e
976
+ ensure
977
+ Rdkafka::Bindings.rd_kafka_message_destroy(ptr)
978
+ end
979
+
980
+ i += 1
981
+ end
982
+ ensure
983
+ while i < count
984
+ ptr = buffer.get_pointer(i * FFI::Pointer.size)
985
+ Rdkafka::Bindings.rd_kafka_message_destroy(ptr) unless ptr.null?
986
+ i += 1
987
+ end
988
+ end
989
+
990
+ results
991
+ end
992
+
588
993
  # Poll for new messages and yield for each received one. Iteration
589
994
  # will end when the consumer is closed.
590
995
  #
591
996
  # If `enable.partition.eof` is turned on in the config this will raise an error when an eof is
592
997
  # reached, so you probably want to disable that when using this method of iteration.
593
998
  #
999
+ # @param timeout_ms [Integer] Timeout for each poll iteration
594
1000
  # @yieldparam message [Message] Received message
595
1001
  # @return [nil]
596
1002
  # @raise [RdkafkaError] When polling fails
597
- def each
1003
+ def each(timeout_ms: Defaults::CONSUMER_POLL_TIMEOUT_MS)
598
1004
  loop do
599
- message = poll(250)
1005
+ message = poll(timeout_ms)
600
1006
  if message
601
1007
  yield(message)
1008
+ elsif closed?
1009
+ break
602
1010
  else
603
- if closed?
604
- break
605
- else
606
- next
607
- end
1011
+ next
608
1012
  end
609
1013
  end
610
1014
  end
611
1015
 
612
- # Deprecated. Please read the error message for more details.
1016
+ # @deprecated This method has been removed due to data consistency concerns
1017
+ # @param max_items [Integer] unused
1018
+ # @param bytes_threshold [Numeric] unused
1019
+ # @param timeout_ms [Integer] unused
1020
+ # @param yield_on_error [Boolean] unused
1021
+ # @param block [Proc] unused block
1022
+ # @raise [NotImplementedError] Always raises as this method is no longer supported
613
1023
  def each_batch(max_items: 100, bytes_threshold: Float::INFINITY, timeout_ms: 250, yield_on_error: false, &block)
614
1024
  raise NotImplementedError, <<~ERROR
615
1025
  `each_batch` has been removed due to data consistency concerns.
@@ -646,8 +1056,52 @@ module Rdkafka
646
1056
 
647
1057
  private
648
1058
 
1059
+ # Checks if the consumer is closed and raises an error if so
1060
+ # @param method [Symbol] name of the calling method for error context
1061
+ # @raise [ClosedConsumerError] when the consumer is closed
649
1062
  def closed_consumer_check(method)
650
1063
  raise Rdkafka::ClosedConsumerError.new(method) if closed?
651
1064
  end
1065
+ alias_method :closed_check, :closed_consumer_check
1066
+
1067
+ # Reads a librdkafka-allocated string and frees the underlying native buffer.
1068
+ #
1069
+ # librdkafka returns heap-allocated, caller-owned strings from functions like
1070
+ # `rd_kafka_clusterid`/`rd_kafka_memberid`. The buffer must be released with
1071
+ # `rd_kafka_mem_free`, otherwise it leaks on every call.
1072
+ #
1073
+ # @param inner [FFI::Pointer] the native client handle used to allocate the string
1074
+ # @param ptr [FFI::Pointer] the native string pointer (may be null)
1075
+ # @return [String, nil] the copied Ruby string, or nil when the pointer is null
1076
+ def read_and_free_native_string(inner, ptr)
1077
+ return nil if ptr.null?
1078
+
1079
+ ptr.read_string
1080
+ ensure
1081
+ Rdkafka::Bindings.rd_kafka_mem_free(inner, ptr) unless ptr.null?
1082
+ end
1083
+
1084
+ # Returns the consumer queue pointer, lazily initialized
1085
+ # @return [FFI::Pointer] consumer queue handle
1086
+ def consumer_queue
1087
+ @consumer_queue ||= @native_kafka.with_inner do |inner|
1088
+ queue = Rdkafka::Bindings.rd_kafka_queue_get_consumer(inner)
1089
+ # Share the pointer with the finalizer so it is destroyed even if the consumer is GC'd
1090
+ # without an explicit close.
1091
+ @consumer_queue_holder[0] = queue
1092
+ queue
1093
+ end
1094
+ end
1095
+
1096
+ # Returns a reusable FFI buffer for batch polling, growing if needed
1097
+ # @param max_items [Integer] minimum buffer capacity
1098
+ # @return [FFI::MemoryPointer] pointer buffer
1099
+ def batch_buffer(max_items)
1100
+ if @batch_buffer.nil? || @batch_buffer_size < max_items
1101
+ @batch_buffer = FFI::MemoryPointer.new(:pointer, max_items)
1102
+ @batch_buffer_size = max_items
1103
+ end
1104
+ @batch_buffer
1105
+ end
652
1106
  end
653
1107
  end