karafka-rdkafka 0.27.2-aarch64-linux-gnu → 0.28.1-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 (67) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +41 -1
  3. data/README.md +4 -3
  4. data/docker-compose-ssl.yml +1 -1
  5. data/docker-compose.yml +1 -1
  6. data/ext/librdkafka.so +0 -0
  7. data/karafka-rdkafka.gemspec +1 -0
  8. data/lib/rdkafka/abstract_handle.rb +37 -4
  9. data/lib/rdkafka/admin/config_binding_result.rb +1 -1
  10. data/lib/rdkafka/admin/create_acl_handle.rb +2 -8
  11. data/lib/rdkafka/admin/create_acl_report.rb +2 -1
  12. data/lib/rdkafka/admin/create_partitions_handle.rb +1 -20
  13. data/lib/rdkafka/admin/create_topic_handle.rb +1 -20
  14. data/lib/rdkafka/admin/delete_acl_handle.rb +2 -10
  15. data/lib/rdkafka/admin/delete_groups_handle.rb +3 -11
  16. data/lib/rdkafka/admin/delete_records_handle.rb +31 -0
  17. data/lib/rdkafka/admin/delete_records_report.rb +25 -0
  18. data/lib/rdkafka/admin/delete_topic_handle.rb +1 -20
  19. data/lib/rdkafka/admin/describe_acl_handle.rb +2 -10
  20. data/lib/rdkafka/admin/describe_acl_report.rb +2 -1
  21. data/lib/rdkafka/admin/describe_configs_handle.rb +2 -13
  22. data/lib/rdkafka/admin/describe_configs_report.rb +5 -6
  23. data/lib/rdkafka/admin/incremental_alter_configs_handle.rb +2 -13
  24. data/lib/rdkafka/admin/incremental_alter_configs_report.rb +5 -6
  25. data/lib/rdkafka/admin/list_consumer_groups_handle.rb +31 -0
  26. data/lib/rdkafka/admin/list_consumer_groups_report.rb +83 -0
  27. data/lib/rdkafka/admin/list_offsets_handle.rb +2 -13
  28. data/lib/rdkafka/admin/list_offsets_report.rb +5 -2
  29. data/lib/rdkafka/admin.rb +233 -145
  30. data/lib/rdkafka/bindings.rb +63 -16
  31. data/lib/rdkafka/callbacks/base_handler.rb +62 -0
  32. data/lib/rdkafka/callbacks/create_acl_handler.rb +37 -0
  33. data/lib/rdkafka/callbacks/create_partitions_handler.rb +37 -0
  34. data/lib/rdkafka/callbacks/create_topic_handler.rb +37 -0
  35. data/lib/rdkafka/callbacks/delete_acl_handler.rb +42 -0
  36. data/lib/rdkafka/callbacks/delete_groups_handler.rb +37 -0
  37. data/lib/rdkafka/callbacks/delete_records_handler.rb +38 -0
  38. data/lib/rdkafka/callbacks/delete_topic_handler.rb +37 -0
  39. data/lib/rdkafka/callbacks/describe_acl_handler.rb +35 -0
  40. data/lib/rdkafka/callbacks/describe_configs_handler.rb +42 -0
  41. data/lib/rdkafka/callbacks/incremental_alter_configs_handler.rb +42 -0
  42. data/lib/rdkafka/callbacks/list_consumer_groups_handler.rb +39 -0
  43. data/lib/rdkafka/callbacks/list_offsets_handler.rb +42 -0
  44. data/lib/rdkafka/callbacks.rb +60 -246
  45. data/lib/rdkafka/config.rb +69 -50
  46. data/lib/rdkafka/consumer/headers.rb +19 -5
  47. data/lib/rdkafka/consumer/topic_partition_list.rb +57 -42
  48. data/lib/rdkafka/consumer.rb +190 -48
  49. data/lib/rdkafka/defaults.rb +32 -3
  50. data/lib/rdkafka/error.rb +14 -5
  51. data/lib/rdkafka/helpers/list_offsets.rb +127 -0
  52. data/lib/rdkafka/helpers/metadata.rb +29 -0
  53. data/lib/rdkafka/helpers/oauth.rb +10 -5
  54. data/lib/rdkafka/metadata.rb +84 -19
  55. data/lib/rdkafka/native_kafka.rb +20 -7
  56. data/lib/rdkafka/producer/delivery_handle.rb +4 -5
  57. data/lib/rdkafka/producer/partitions_count_cache.rb +24 -38
  58. data/lib/rdkafka/producer/testing.rb +2 -1
  59. data/lib/rdkafka/producer.rb +100 -71
  60. data/lib/rdkafka/version.rb +3 -3
  61. data/lib/rdkafka.rb +19 -0
  62. data/package-lock.json +6 -6
  63. data/renovate.json +11 -1
  64. metadata +21 -5
  65. data/Gemfile +0 -13
  66. data/Gemfile.lint +0 -14
  67. data/Gemfile.lint.lock +0 -123
@@ -7,6 +7,11 @@ module Rdkafka
7
7
  # Empty frozen hash used when there are no headers
8
8
  EMPTY_HEADERS = {}.freeze
9
9
 
10
+ # Key under which reusable scratch pointers are stored on the current thread/fiber
11
+ SCRATCH_KEY = :rdkafka_headers_scratch
12
+
13
+ private_constant :SCRATCH_KEY
14
+
10
15
  # Reads a librdkafka native message's headers and returns them as a Ruby Hash
11
16
  # where each key maps to either a String (single value) or Array<String> (multiple values)
12
17
  # to support duplicate headers per KIP-82
@@ -17,7 +22,20 @@ module Rdkafka
17
22
  # @return [Hash{String => String, Array<String>}] headers Hash for the native_message
18
23
  # @raise [Rdkafka::RdkafkaError] when fail to read headers
19
24
  def self.from_native(native_message)
20
- headers_ptrptr = FFI::MemoryPointer.new(:pointer)
25
+ # Scratch output pointers for the header FFI calls, reused across messages. They are
26
+ # stored in fiber-local storage (`Thread.current[]` is fiber-local by design), so each
27
+ # thread and each fiber gets its own set and reuse is safe with fiber schedulers. They
28
+ # never escape this method and all data is read out of them before returning.
29
+ # Allocating them per message would cost several native allocations for every consumed
30
+ # message.
31
+ headers_ptrptr, name_ptrptr, value_ptrptr, size_ptr =
32
+ Thread.current[SCRATCH_KEY] ||= [
33
+ FFI::MemoryPointer.new(:pointer),
34
+ FFI::MemoryPointer.new(:pointer),
35
+ FFI::MemoryPointer.new(:pointer),
36
+ Rdkafka::Bindings::SizePtr.new
37
+ ].freeze
38
+
21
39
  err = Rdkafka::Bindings.rd_kafka_message_headers(native_message, headers_ptrptr)
22
40
 
23
41
  if err == Rdkafka::Bindings::RD_KAFKA_RESP_ERR__NOENT
@@ -28,10 +46,6 @@ module Rdkafka
28
46
 
29
47
  headers_ptr = headers_ptrptr.read_pointer
30
48
 
31
- name_ptrptr = FFI::MemoryPointer.new(:pointer)
32
- value_ptrptr = FFI::MemoryPointer.new(:pointer)
33
- size_ptr = Rdkafka::Bindings::SizePtr.new
34
-
35
49
  headers = {}
36
50
 
37
51
  idx = 0
@@ -6,7 +6,8 @@ module Rdkafka
6
6
  class TopicPartitionList
7
7
  # Create a topic partition list.
8
8
  #
9
- # @param data [Hash{String => nil,Partition}] The topic and partition data or nil to create an empty list
9
+ # @param data [Hash{String => nil,Partition}] The topic and partition data or nil to create an
10
+ # empty list
10
11
  #
11
12
  # @return [TopicPartitionList]
12
13
  def initialize(data = nil)
@@ -34,10 +35,12 @@ module Rdkafka
34
35
  end
35
36
 
36
37
  # Add a topic with optionally partitions to the list.
37
- # Calling this method multiple times for the same topic will overwrite the previous configuraton.
38
+ # Calling this method multiple times for the same topic will overwrite the previous
39
+ # configuraton.
38
40
  #
39
41
  # @param topic [String] The topic's name
40
- # @param partitions [Array<Integer>, Range<Integer>, Integer] The topic's partitions or partition count
42
+ # @param partitions [Array<Integer>, Range<Integer>, Integer] The topic's partitions or
43
+ # partition count
41
44
  #
42
45
  # @return [nil]
43
46
  #
@@ -61,11 +64,12 @@ module Rdkafka
61
64
  end
62
65
 
63
66
  # Add a topic with partitions and offsets set to the list
64
- # Calling this method multiple times for the same topic will overwrite the previous configuraton.
67
+ # Calling this method multiple times for the same topic will overwrite the previous
68
+ # configuraton.
65
69
  #
66
70
  # @param topic [String] The topic's name
67
- # @param partitions_with_offsets [Hash{Integer => Integer}, Array<Consumer::Partition>] The topic's
68
- # partitions and offsets (Hash) or partitions with offsets and metadata (Array)
71
+ # @param partitions_with_offsets [Hash{Integer => Integer}, Array<Consumer::Partition>] The
72
+ # topic's partitions and offsets (Hash) or partitions with offsets and metadata (Array)
69
73
  # @return [nil]
70
74
  def add_topic_and_partitions_with_offsets(topic, partitions_with_offsets)
71
75
  @data[topic.to_s] = partitions_with_offsets.map do |p, o|
@@ -73,7 +77,8 @@ module Rdkafka
73
77
  end
74
78
  end
75
79
 
76
- # Return a `Hash` with the topics as keys and and an array of partition information as the value if present.
80
+ # Return a `Hash` with the topics as keys and and an array of partition information as the
81
+ # value if present.
77
82
  #
78
83
  # @return [Hash{String => Array<Partition>,nil}]
79
84
  def to_h
@@ -97,7 +102,8 @@ module Rdkafka
97
102
  #
98
103
  # @private
99
104
  #
100
- # @param pointer [FFI::Pointer] Optional pointer to an existing native list. Its contents will be copied.
105
+ # @param pointer [FFI::Pointer] Optional pointer to an existing native list. Its contents will
106
+ # be copied.
101
107
  #
102
108
  # @return [TopicPartitionList]
103
109
  def self.from_native_tpl(pointer)
@@ -118,7 +124,6 @@ module Rdkafka
118
124
  else
119
125
  elem[:offset]
120
126
  end
121
-
122
127
  partition = Partition.new(
123
128
  elem[:partition],
124
129
  offset,
@@ -136,51 +141,61 @@ module Rdkafka
136
141
 
137
142
  # Create a native tpl with the contents of this object added.
138
143
  #
139
- # The pointer will be cleaned by `rd_kafka_topic_partition_list_destroy` when GC releases it.
144
+ # The returned pointer is caller-owned and must be released with
145
+ # `rd_kafka_topic_partition_list_destroy`. It is not cleaned up by GC.
140
146
  #
141
147
  # @private
142
148
  # @return [FFI::Pointer]
143
149
  def to_native_tpl
144
150
  tpl = Rdkafka::Bindings.rd_kafka_topic_partition_list_new(count)
145
151
 
146
- @data.each do |topic, partitions|
147
- if partitions
148
- partitions.each do |p|
149
- ref = Rdkafka::Bindings.rd_kafka_topic_partition_list_add(
150
- tpl,
151
- topic,
152
- p.partition
153
- )
154
-
155
- # Remove the respond to check after karafka 2.3.0 is released
156
- if p.respond_to?(:metadata) && p.metadata
157
- part = Rdkafka::Bindings::TopicPartition.new(ref)
158
- str_ptr = FFI::MemoryPointer.from_string(p.metadata)
159
- # released here:
160
- # https://github.com/confluentinc/librdkafka/blob/e03d3bb91ed92a38f38d9806b8d8deffe78a1de5/src/rdkafka_partition.c#L2682C18-L2682C18
161
- str_ptr.autorelease = false
162
- part[:metadata] = str_ptr
163
- part[:metadata_size] = p.metadata.bytesize
164
- end
165
-
166
- if p.offset
167
- offset = p.offset.is_a?(Time) ? p.offset.to_f * 1_000 : p.offset
168
-
169
- Rdkafka::Bindings.rd_kafka_topic_partition_list_set_offset(
152
+ begin
153
+ @data.each do |topic, partitions|
154
+ if partitions
155
+ partitions.each do |p|
156
+ ref = Rdkafka::Bindings.rd_kafka_topic_partition_list_add(
170
157
  tpl,
171
158
  topic,
172
- p.partition,
173
- offset
159
+ p.partition
174
160
  )
161
+
162
+ # Remove the respond to check after karafka 2.3.0 is released
163
+ if p.respond_to?(:metadata) && p.metadata
164
+ part = Rdkafka::Bindings::TopicPartition.new(ref)
165
+ str_ptr = FFI::MemoryPointer.from_string(p.metadata)
166
+ # released here:
167
+ # https://github.com/confluentinc/librdkafka/blob/e03d3bb91ed92a38f38d9806b8d8deffe78a1de5/src/rdkafka_partition.c#L2682C18-L2682C18
168
+ str_ptr.autorelease = false
169
+ part[:metadata] = str_ptr
170
+ part[:metadata_size] = p.metadata.bytesize
171
+ end
172
+
173
+ if p.offset
174
+ offset = p.offset.is_a?(Time) ? p.offset.to_f * 1_000 : p.offset
175
+
176
+ Rdkafka::Bindings.rd_kafka_topic_partition_list_set_offset(
177
+ tpl,
178
+ topic,
179
+ p.partition,
180
+ offset
181
+ )
182
+ end
175
183
  end
184
+ else
185
+ Rdkafka::Bindings.rd_kafka_topic_partition_list_add(
186
+ tpl,
187
+ topic,
188
+ Rdkafka::Bindings::RD_KAFKA_PARTITION_UA
189
+ )
176
190
  end
177
- else
178
- Rdkafka::Bindings.rd_kafka_topic_partition_list_add(
179
- tpl,
180
- topic,
181
- Rdkafka::Bindings::RD_KAFKA_PARTITION_UA
182
- )
183
191
  end
192
+ rescue Exception
193
+ # The native list is caller-owned and only handed back on success. If population raises
194
+ # partway (e.g. non-string metadata, or an offset FFI can't coerce to int64) we must
195
+ # destroy it here, otherwise the half-built list leaks.
196
+ Rdkafka::Bindings.rd_kafka_topic_partition_list_destroy(tpl)
197
+
198
+ raise
184
199
  end
185
200
 
186
201
  tpl
@@ -14,14 +14,48 @@ module Rdkafka
14
14
  include Enumerable
15
15
  include Helpers::Time
16
16
  include Helpers::OAuth
17
+ include Helpers::Metadata
18
+ include Helpers::ListOffsets
17
19
 
18
20
  # @private
19
21
  # @param native_kafka [NativeKafka] wrapper around the native Kafka consumer handle
20
22
  def initialize(native_kafka)
21
23
  @native_kafka = native_kafka
24
+ # Single-element holder shared with the GC finalizer so it can destroy the lazily created
25
+ # consumer queue without capturing `self` (capturing the consumer in its own finalizer would
26
+ # pin it and prevent it from ever being collected).
27
+ @consumer_queue_holder = []
28
+
29
+ # Makes sure the consumer is closed (consumer queue destroyed and native client destroyed)
30
+ # before it gets GCed by Ruby.
31
+ ObjectSpace.define_finalizer(self, self.class.finalizer(native_kafka, @consumer_queue_holder))
32
+ end
33
+
34
+ # Builds the GC finalizer for a consumer. It mirrors {#close}: close the consumer, destroy the
35
+ # consumer-queue reference, then destroy the native client. The default `NativeKafka#finalizer`
36
+ # went straight to `rd_kafka_destroy`, leaving the consumer-queue reference (from
37
+ # `rd_kafka_queue_get_consumer`, taken by `poll_batch`) dangling - which can make
38
+ # `rd_kafka_destroy` block inside the finalizer (process hang at GC/shutdown) or leak the
39
+ # handle.
40
+ #
41
+ # @private
42
+ # @param native_kafka [NativeKafka] the wrapped native client
43
+ # @param queue_holder [Array] single-element holder carrying the consumer queue pointer
44
+ # (or empty)
45
+ # @return [Proc] finalizer proc that must not reference the consumer instance
46
+ def self.finalizer(native_kafka, queue_holder)
47
+ proc do
48
+ next if native_kafka.closed?
49
+
50
+ native_kafka.synchronize do |inner|
51
+ Rdkafka::Bindings.rd_kafka_consumer_close(inner)
52
+
53
+ queue = queue_holder[0]
54
+ Rdkafka::Bindings.rd_kafka_queue_destroy(queue) if queue
55
+ end
22
56
 
23
- # Makes sure, that native kafka gets closed before it gets GCed by Ruby
24
- ObjectSpace.define_finalizer(self, native_kafka.finalizer)
57
+ native_kafka.close
58
+ end
25
59
  end
26
60
 
27
61
  # Starts the native Kafka polling thread and kicks off the init polling
@@ -93,11 +127,6 @@ module Rdkafka
93
127
  # @return [nil]
94
128
  # @raise [Rdkafka::ClosedConsumerError] if called on a closed consumer
95
129
  #
96
- # @note This method holds the inner lock until the queue is empty or `:stop` is returned.
97
- # Other consumer operations will wait until this method returns.
98
- # @note This method is thread-safe as it uses @native_kafka.with_inner synchronization
99
- # @note Do NOT use this if `consumer_poll_set` was set to `true`
100
- #
101
130
  # @example Drain all pending events
102
131
  # consumer.events_poll_nb_each { |_count| }
103
132
  #
@@ -106,6 +135,10 @@ module Rdkafka
106
135
  # consumer.events_poll_nb_each do |_count|
107
136
  # :stop if monotonic_now >= deadline
108
137
  # end
138
+ # @note This method holds the inner lock until the queue is empty or `:stop` is returned.
139
+ # Other consumer operations will wait until this method returns.
140
+ # @note This method is thread-safe as it uses @native_kafka.with_inner synchronization
141
+ # @note Do NOT use this if `consumer_poll_set` was set to `true`
109
142
  def events_poll_nb_each
110
143
  closed_consumer_check(__method__)
111
144
 
@@ -131,14 +164,6 @@ module Rdkafka
131
164
  # @raise [Rdkafka::ClosedConsumerError] if called on a closed consumer
132
165
  # @raise [Rdkafka::RdkafkaError] if a Kafka error occurs while polling
133
166
  #
134
- # @note This method uses `rd_kafka_consumer_poll` to fetch messages, unlike
135
- # `events_poll_nb_each` which uses `rd_kafka_poll` for event callbacks (delivery reports,
136
- # statistics, etc.). For consumers, use this method to receive messages and
137
- # `events_poll_nb_each` for processing background events.
138
- # @note This method holds the inner lock for the duration. Other consumer operations
139
- # will wait until this method returns.
140
- # @note Timeout/max_messages logic should be implemented by the caller
141
- #
142
167
  # @example Process messages until queue is empty
143
168
  # consumer.poll_nb_each do |message|
144
169
  # process(message)
@@ -151,6 +176,13 @@ module Rdkafka
151
176
  # count += 1
152
177
  # :stop if count >= 10
153
178
  # end
179
+ # @note This method uses `rd_kafka_consumer_poll` to fetch messages, unlike
180
+ # `events_poll_nb_each` which uses `rd_kafka_poll` for event callbacks (delivery reports,
181
+ # statistics, etc.). For consumers, use this method to receive messages and
182
+ # `events_poll_nb_each` for processing background events.
183
+ # @note This method holds the inner lock for the duration. Other consumer operations
184
+ # will wait until this method returns.
185
+ # @note Timeout/max_messages logic should be implemented by the caller
154
186
  def poll_nb_each
155
187
  closed_consumer_check(__method__)
156
188
 
@@ -187,6 +219,7 @@ module Rdkafka
187
219
  if @consumer_queue
188
220
  Rdkafka::Bindings.rd_kafka_queue_destroy(@consumer_queue)
189
221
  @consumer_queue = nil
222
+ @consumer_queue_holder[0] = nil
190
223
  end
191
224
  end
192
225
 
@@ -404,14 +437,18 @@ module Rdkafka
404
437
  end
405
438
 
406
439
  # Return the current positions (offsets) for topics and partitions.
407
- # The offset field of each requested partition will be set to the offset of the last consumed message + 1, or nil in case there was no previous message.
440
+ # The offset field of each requested partition will be set to the offset of the last consumed
441
+ # message + 1, or nil in case there was no previous message.
408
442
  #
409
- # @param list [TopicPartitionList, nil] The topic with partitions to get the offsets for or nil to use the current subscription.
443
+ # @param list [TopicPartitionList, nil] The topic with partitions to get the offsets for or nil
444
+ # to use the current subscription.
410
445
  #
411
446
  # @return [TopicPartitionList]
412
447
  #
413
448
  # @raise [RdkafkaError] When getting the positions fails.
414
449
  def position(list = nil)
450
+ closed_consumer_check(__method__)
451
+
415
452
  if list.nil?
416
453
  list = assignment
417
454
  elsif !list.is_a?(TopicPartitionList)
@@ -466,39 +503,77 @@ module Rdkafka
466
503
  # possible to create one yourself, in this case you have to provide a list that
467
504
  # already contains all the partitions you need the lag for.
468
505
  #
506
+ # The end offsets of all requested partitions are fetched in a single batched
507
+ # {#list_offsets} query - librdkafka fans it out to the involved partition leaders
508
+ # internally and concurrently - instead of one blocking {#query_watermark_offsets}
509
+ # broker roundtrip per partition.
510
+ #
469
511
  # @param topic_partition_list [TopicPartitionList] The list to calculate lag for.
470
- # @param watermark_timeout_ms [Integer] The timeout for each query watermark call.
512
+ # @param watermark_timeout_ms [Integer] The timeout for the batched end-offsets query.
471
513
  # @return [Hash{String => Hash{Integer => Integer}}] A hash containing all topics with the lag
472
514
  # per partition
515
+ # @raise [ClosedConsumerError] when the consumer is closed
473
516
  # @raise [RdkafkaError] When querying the broker fails.
474
517
  def lag(topic_partition_list, watermark_timeout_ms = Defaults::CONSUMER_LAG_TIMEOUT_MS)
518
+ closed_consumer_check(__method__)
519
+
475
520
  out = {}
521
+ request = {}
522
+ partitions_by_topic = topic_partition_list.to_h
523
+
524
+ partitions_by_topic.each do |topic, partitions|
525
+ out[topic] = {}
526
+
527
+ partitions.each do |p|
528
+ next if p.offset.nil?
529
+
530
+ (request[topic] ||= []) << { partition: p.partition, offset: :latest }
531
+ end
532
+ end
533
+
534
+ return out if request.empty?
535
+
536
+ report = begin
537
+ # The isolation level is forwarded so the end offsets match what the old per-partition
538
+ # watermark query returned: librdkafka resolves that query with the consumer's configured
539
+ # `isolation.level` (LSO for the default read_committed), while the admin-style batched
540
+ # query would otherwise default to read_uncommitted (true high watermark).
541
+ list_offsets(request, isolation_level: isolation_level)
542
+ .wait(max_wait_timeout_ms: watermark_timeout_ms)
543
+ rescue AbstractHandle::WaitTimeoutError
544
+ # Keep the pre-batching contract: a slow broker surfaced as a timed-out RdkafkaError
545
+ # from the per-partition watermark query, not as a handle wait timeout.
546
+ raise RdkafkaError.new(
547
+ Rdkafka::Bindings::RD_KAFKA_RESP_ERR__TIMED_OUT,
548
+ "Error querying watermark offsets of '#{request.keys.join(", ")}'"
549
+ )
550
+ end
551
+
552
+ end_offsets = {}
553
+ report.offsets.each do |result|
554
+ (end_offsets[result[:topic]] ||= {})[result[:partition]] = result[:offset]
555
+ end
476
556
 
477
- topic_partition_list.to_h.each do |topic, partitions|
478
- # Query high watermarks for this topic's partitions
479
- # and compare to the offset in the list.
480
- topic_out = {}
557
+ partitions_by_topic.each do |topic, partitions|
481
558
  partitions.each do |p|
482
559
  next if p.offset.nil?
483
- _low, high = query_watermark_offsets(
484
- topic,
485
- p.partition,
486
- watermark_timeout_ms
487
- )
488
- topic_out[p.partition] = high - p.offset
560
+
561
+ out[topic][p.partition] = end_offsets.fetch(topic).fetch(p.partition) - p.offset
489
562
  end
490
- out[topic] = topic_out
491
563
  end
564
+
492
565
  out
493
566
  end
494
567
 
495
568
  # Returns the ClusterId as reported in broker metadata.
496
569
  #
497
- # @return [String, nil]
498
- def cluster_id
570
+ # @param timeout_ms [Integer] how long to block waiting for metadata when the cluster id is
571
+ # not already cached (0 for a non-blocking call)
572
+ # @return [String, nil] the cluster id, or nil if it could not be retrieved in time
573
+ def cluster_id(timeout_ms = Defaults::CONSUMER_CLUSTER_ID_TIMEOUT_MS)
499
574
  closed_consumer_check(__method__)
500
575
  @native_kafka.with_inner do |inner|
501
- Rdkafka::Bindings.rd_kafka_clusterid(inner)
576
+ read_and_free_native_string(inner, Rdkafka::Bindings.rd_kafka_clusterid(inner, timeout_ms))
502
577
  end
503
578
  end
504
579
 
@@ -510,7 +585,7 @@ module Rdkafka
510
585
  def member_id
511
586
  closed_consumer_check(__method__)
512
587
  @native_kafka.with_inner do |inner|
513
- Rdkafka::Bindings.rd_kafka_memberid(inner)
588
+ read_and_free_native_string(inner, Rdkafka::Bindings.rd_kafka_memberid(inner))
514
589
  end
515
590
  end
516
591
 
@@ -519,7 +594,7 @@ module Rdkafka
519
594
  # When using this `enable.auto.offset.store` should be set to `false` in the config.
520
595
  #
521
596
  # @param message [Rdkafka::Consumer::Message] The message which offset will be stored
522
- # @param metadata [String, nil] commit metadata string or nil if none
597
+ # @param metadata [String, nil] commit metadata string to store alongside the offset
523
598
  # @return [nil]
524
599
  # @raise [RdkafkaError] When storing the offset fails
525
600
  def store_offset(message, metadata = nil)
@@ -573,9 +648,8 @@ module Rdkafka
573
648
  seek_by(message.topic, message.partition, message.offset)
574
649
  end
575
650
 
576
- # Seek to a particular message by providing the topic, partition and offset.
577
- # The next poll on the topic/partition will return the
578
- # message at the given offset.
651
+ # Seek to a particular message by providing the topic, partition and offset. The next poll on
652
+ # the topic/partition will return the message at the given offset.
579
653
  #
580
654
  # @param topic [String] The topic in which to seek
581
655
  # @param partition [Integer] The partition number to seek
@@ -802,7 +876,7 @@ module Rdkafka
802
876
  # returns without further waiting.
803
877
  #
804
878
  # Error events (e.g. `:partition_eof`) are returned inline as {RdkafkaError} objects
805
- # rather than raised, so callers receive the complete batch — both messages and errors —
879
+ # rather than raised, so callers receive the complete batch - both messages and errors -
806
880
  # and can decide how to handle each. This is particularly useful when multiple partitions
807
881
  # signal EOF simultaneously: all signals appear in the returned array rather than only
808
882
  # the first one being raised and the rest silently discarded.
@@ -847,8 +921,17 @@ module Rdkafka
847
921
  next
848
922
  end
849
923
 
850
- results << Rdkafka::Consumer::Message.new(native_message)
851
- Rdkafka::Bindings.rd_kafka_message_destroy(ptr)
924
+ begin
925
+ results << Rdkafka::Consumer::Message.new(native_message)
926
+ rescue Rdkafka::RdkafkaError => e
927
+ # A message that fails to build (e.g. a header read error) is surfaced inline as an
928
+ # error event rather than discarding the whole batch - including the messages already
929
+ # built - and raising, which silently lost them once their offsets had been stored.
930
+ results << e
931
+ ensure
932
+ Rdkafka::Bindings.rd_kafka_message_destroy(ptr)
933
+ end
934
+
852
935
  i += 1
853
936
  end
854
937
  ensure
@@ -868,15 +951,15 @@ module Rdkafka
868
951
  # particularly useful in fiber scheduler contexts where GVL release/reacquire
869
952
  # overhead is wasteful since we don't expect to wait.
870
953
  #
954
+ # @param timeout_ms [Integer] Timeout waiting for the first message
955
+ # (default: 0 for non-blocking)
956
+ # @param max_items [Integer] Maximum number of messages to return per call
957
+ # @return [Array<Message, RdkafkaError>] Batch of messages and/or error events in arrival order
958
+ # @raise [ClosedConsumerError] When called on a closed consumer
871
959
  # @note Since the GVL is not released, a non-zero timeout_ms will block all Ruby
872
960
  # threads/fibers for the duration. Use {#poll_batch} if you need a blocking wait.
873
961
  #
874
962
  # Error events are returned inline as {RdkafkaError} objects; see {#poll_batch} for details.
875
- #
876
- # @param timeout_ms [Integer] Timeout waiting for the first message (default: 0 for non-blocking)
877
- # @param max_items [Integer] Maximum number of messages to return per call
878
- # @return [Array<Message, RdkafkaError>] Batch of messages and/or error events in arrival order
879
- # @raise [ClosedConsumerError] When called on a closed consumer
880
963
  def poll_batch_nb(timeout_ms = 0, max_items: 100)
881
964
  closed_consumer_check(__method__)
882
965
 
@@ -913,8 +996,17 @@ module Rdkafka
913
996
  next
914
997
  end
915
998
 
916
- results << Rdkafka::Consumer::Message.new(native_message)
917
- Rdkafka::Bindings.rd_kafka_message_destroy(ptr)
999
+ begin
1000
+ results << Rdkafka::Consumer::Message.new(native_message)
1001
+ rescue Rdkafka::RdkafkaError => e
1002
+ # A message that fails to build (e.g. a header read error) is surfaced inline as an
1003
+ # error event rather than discarding the whole batch - including the messages already
1004
+ # built - and raising, which silently lost them once their offsets had been stored.
1005
+ results << e
1006
+ ensure
1007
+ Rdkafka::Bindings.rd_kafka_message_destroy(ptr)
1008
+ end
1009
+
918
1010
  i += 1
919
1011
  end
920
1012
  ensure
@@ -1000,12 +1092,62 @@ module Rdkafka
1000
1092
  def closed_consumer_check(method)
1001
1093
  raise Rdkafka::ClosedConsumerError.new(method) if closed?
1002
1094
  end
1095
+ alias_method :closed_check, :closed_consumer_check
1096
+
1097
+ # Reads a librdkafka-allocated string and frees the underlying native buffer.
1098
+ #
1099
+ # `rd_kafka_memberid`/`rd_kafka_clusterid` return a string the caller owns and must release
1100
+ # with `rd_kafka_mem_free`; without this the buffer leaks on every call.
1101
+ #
1102
+ # @param inner [FFI::Pointer] the native client handle (needed to free the buffer)
1103
+ # @param ptr [FFI::Pointer] the librdkafka-allocated string, possibly NULL
1104
+ # @return [String, nil] the copied string, or nil when the pointer was NULL
1105
+ def read_and_free_native_string(inner, ptr)
1106
+ return nil if ptr.null?
1107
+
1108
+ ptr.read_string
1109
+ ensure
1110
+ Rdkafka::Bindings.rd_kafka_mem_free(inner, ptr) unless ptr.null?
1111
+ end
1112
+
1113
+ # Reads this consumer's effective `isolation.level` from the live librdkafka configuration
1114
+ # and maps it to the numeric isolation level constant. Memoized: the value cannot change
1115
+ # after client creation.
1116
+ #
1117
+ # @return [Integer] `RD_KAFKA_ISOLATION_LEVEL_READ_COMMITTED` or
1118
+ # `RD_KAFKA_ISOLATION_LEVEL_READ_UNCOMMITTED`
1119
+ # @raise [Rdkafka::Config::ConfigError] when the property cannot be read
1120
+ def isolation_level
1121
+ @isolation_level ||= @native_kafka.with_inner do |inner|
1122
+ conf = Rdkafka::Bindings.rd_kafka_conf(inner)
1123
+
1124
+ size_ptr = Rdkafka::Bindings::SizePtr.new
1125
+ size_ptr[:value] = 64
1126
+ value_ptr = FFI::MemoryPointer.new(:char, 64)
1127
+
1128
+ result = Rdkafka::Bindings.rd_kafka_conf_get(conf, "isolation.level", value_ptr, size_ptr)
1129
+
1130
+ if result != :config_ok
1131
+ raise Rdkafka::Config::ConfigError.new("Could not read isolation.level: #{result}")
1132
+ end
1133
+
1134
+ if value_ptr.read_string == "read_committed"
1135
+ Rdkafka::Bindings::RD_KAFKA_ISOLATION_LEVEL_READ_COMMITTED
1136
+ else
1137
+ Rdkafka::Bindings::RD_KAFKA_ISOLATION_LEVEL_READ_UNCOMMITTED
1138
+ end
1139
+ end
1140
+ end
1003
1141
 
1004
1142
  # Returns the consumer queue pointer, lazily initialized
1005
1143
  # @return [FFI::Pointer] consumer queue handle
1006
1144
  def consumer_queue
1007
1145
  @consumer_queue ||= @native_kafka.with_inner do |inner|
1008
- Rdkafka::Bindings.rd_kafka_queue_get_consumer(inner)
1146
+ queue = Rdkafka::Bindings.rd_kafka_queue_get_consumer(inner)
1147
+ # Share the pointer with the finalizer so it is destroyed even if the consumer is GC'd
1148
+ # without an explicit close.
1149
+ @consumer_queue_holder[0] = queue
1150
+ queue
1009
1151
  end
1010
1152
  end
1011
1153
 
@@ -3,8 +3,17 @@
3
3
  module Rdkafka
4
4
  # Provides default timeout and configuration values used throughout the library.
5
5
  #
6
- # These constants standardize timing values across consumers, producers, and admin clients.
7
- # Values are specified in milliseconds (ms) unless otherwise noted.
6
+ # All timeout values can be overridden per-call via method parameters. These constants provide
7
+ # a central place to understand and reference the default values used across the library.
8
+ #
9
+ # @example Overriding a timeout per-call
10
+ # consumer.committed(timeout_ms: 5_000) # Use 5 seconds instead of default 2 seconds
11
+ #
12
+ # @example Checking the default value
13
+ # Rdkafka::Defaults::CONSUMER_COMMITTED_TIMEOUT_MS # => 2000
14
+ # @note These are rdkafka-ruby defaults, not librdkafka configuration options.
15
+ # For librdkafka options, see:
16
+ # https://github.com/confluentinc/librdkafka/blob/master/CONFIGURATION.md
8
17
  module Defaults
9
18
  # Consumer timeout for fetching committed offsets
10
19
  # @see Consumer#committed
@@ -22,6 +31,10 @@ module Rdkafka
22
31
  # @see Consumer#offsets_for_times
23
32
  CONSUMER_OFFSETS_FOR_TIMES_TIMEOUT_MS = 1_000
24
33
 
34
+ # Consumer timeout for fetching the cluster id when it is not already cached from metadata
35
+ # @see Consumer#cluster_id
36
+ CONSUMER_CLUSTER_ID_TIMEOUT_MS = 1_000
37
+
25
38
  # Consumer timeout for poll operations (used in each iteration)
26
39
  # @see Consumer#each
27
40
  CONSUMER_POLL_TIMEOUT_MS = 250
@@ -55,14 +68,30 @@ module Rdkafka
55
68
  # @see Admin#metadata
56
69
  METADATA_TIMEOUT_MS = 2_000
57
70
 
58
- # Maximum retries for metadata requests on transient errors
71
+ # Hard ceiling on metadata fetch attempts on transient errors (backstop; the retry budget
72
+ # normally ends the loop first)
59
73
  # @see Metadata#initialize
60
74
  METADATA_MAX_RETRIES = 10
61
75
 
76
+ # Minimum metadata fetch attempts before the retry budget may end the loop, so a slow broker
77
+ # (whose requests each consume the full timeout) still gets a few tries
78
+ # @see Metadata#initialize
79
+ METADATA_MIN_ATTEMPTS = 3
80
+
81
+ # Soft wall-clock budget for the whole metadata retry loop; past it (and past
82
+ # METADATA_MIN_ATTEMPTS) the loop stops so a synchronous fetch cannot block the caller for long
83
+ # @see Metadata#initialize
84
+ METADATA_RETRY_BUDGET_MS = 5_000
85
+
62
86
  # Base backoff time for metadata retry (100ms = 0.1s)
63
87
  # @see Metadata#initialize
64
88
  METADATA_RETRY_BACKOFF_BASE_MS = 100
65
89
 
90
+ # Maximum backoff time between metadata retries. Caps the exponential backoff so a long retry
91
+ # sequence against an unhealthy cluster cannot block the calling thread for minutes.
92
+ # @see Metadata#initialize
93
+ METADATA_RETRY_BACKOFF_MAX_MS = 1_000
94
+
66
95
  # Default wait timeout for operation handles
67
96
  # @see AbstractHandle#wait
68
97
  HANDLE_WAIT_TIMEOUT_MS = 60_000