rdkafka 0.27.0-aarch64-linux-gnu → 0.29.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 (55) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +38 -0
  3. data/README.md +2 -0
  4. data/docker-compose-ssl.yml +1 -1
  5. data/docker-compose.yml +1 -1
  6. data/ext/librdkafka.so +0 -0
  7. data/lib/rdkafka/abstract_handle.rb +31 -2
  8. data/lib/rdkafka/admin/config_binding_result.rb +2 -2
  9. data/lib/rdkafka/admin/create_acl_handle.rb +6 -5
  10. data/lib/rdkafka/admin/create_partitions_handle.rb +4 -6
  11. data/lib/rdkafka/admin/create_topic_handle.rb +4 -6
  12. data/lib/rdkafka/admin/delete_acl_handle.rb +5 -7
  13. data/lib/rdkafka/admin/delete_groups_handle.rb +4 -6
  14. data/lib/rdkafka/admin/delete_topic_handle.rb +4 -6
  15. data/lib/rdkafka/admin/describe_acl_handle.rb +5 -7
  16. data/lib/rdkafka/admin/describe_configs_handle.rb +4 -10
  17. data/lib/rdkafka/admin/describe_configs_report.rb +1 -5
  18. data/lib/rdkafka/admin/incremental_alter_configs_handle.rb +4 -10
  19. data/lib/rdkafka/admin/incremental_alter_configs_report.rb +1 -5
  20. data/lib/rdkafka/admin/list_offsets_handle.rb +5 -10
  21. data/lib/rdkafka/admin.rb +75 -43
  22. data/lib/rdkafka/bindings.rb +9 -4
  23. data/lib/rdkafka/callbacks/base_handler.rb +62 -0
  24. data/lib/rdkafka/callbacks/create_acl_handler.rb +37 -0
  25. data/lib/rdkafka/callbacks/create_partitions_handler.rb +37 -0
  26. data/lib/rdkafka/callbacks/create_topic_handler.rb +37 -0
  27. data/lib/rdkafka/callbacks/delete_acl_handler.rb +42 -0
  28. data/lib/rdkafka/callbacks/delete_groups_handler.rb +37 -0
  29. data/lib/rdkafka/callbacks/delete_topic_handler.rb +37 -0
  30. data/lib/rdkafka/callbacks/describe_acl_handler.rb +35 -0
  31. data/lib/rdkafka/callbacks/describe_configs_handler.rb +42 -0
  32. data/lib/rdkafka/callbacks/incremental_alter_configs_handler.rb +42 -0
  33. data/lib/rdkafka/callbacks/list_offsets_handler.rb +42 -0
  34. data/lib/rdkafka/callbacks.rb +50 -244
  35. data/lib/rdkafka/config.rb +45 -33
  36. data/lib/rdkafka/consumer/headers.rb +19 -5
  37. data/lib/rdkafka/consumer/partition.rb +8 -1
  38. data/lib/rdkafka/consumer/topic_partition_list.rb +44 -22
  39. data/lib/rdkafka/consumer.rb +135 -33
  40. data/lib/rdkafka/defaults.rb +19 -0
  41. data/lib/rdkafka/helpers/metadata.rb +29 -0
  42. data/lib/rdkafka/metadata.rb +86 -19
  43. data/lib/rdkafka/native_kafka.rb +2 -4
  44. data/lib/rdkafka/producer/delivery_handle.rb +3 -3
  45. data/lib/rdkafka/producer/partitions_count_cache.rb +24 -38
  46. data/lib/rdkafka/producer.rb +56 -44
  47. data/lib/rdkafka/version.rb +3 -3
  48. data/lib/rdkafka.rb +12 -0
  49. data/package-lock.json +6 -6
  50. data/rdkafka.gemspec +1 -0
  51. data/renovate.json +13 -2
  52. metadata +14 -5
  53. data/Gemfile +0 -13
  54. data/Gemfile.lint +0 -14
  55. data/Gemfile.lint.lock +0 -123
@@ -118,7 +118,12 @@ module Rdkafka
118
118
  else
119
119
  elem[:offset]
120
120
  end
121
- partition = Partition.new(elem[:partition], offset, elem[:err])
121
+ partition = Partition.new(
122
+ elem[:partition],
123
+ offset,
124
+ elem[:err],
125
+ elem[:metadata].null? ? nil : elem[:metadata].read_string(elem[:metadata_size])
126
+ )
122
127
  partitions.push(partition)
123
128
  data[elem[:topic]] = partitions
124
129
  end
@@ -137,33 +142,50 @@ module Rdkafka
137
142
  def to_native_tpl
138
143
  tpl = Rdkafka::Bindings.rd_kafka_topic_partition_list_new(count)
139
144
 
140
- @data.each do |topic, partitions|
141
- if partitions
142
- partitions.each do |p|
143
- Rdkafka::Bindings.rd_kafka_topic_partition_list_add(
144
- tpl,
145
- topic,
146
- p.partition
147
- )
148
-
149
- if p.offset
150
- offset = p.offset.is_a?(Time) ? p.offset.to_f * 1_000 : p.offset
151
-
152
- Rdkafka::Bindings.rd_kafka_topic_partition_list_set_offset(
145
+ begin
146
+ @data.each do |topic, partitions|
147
+ if partitions
148
+ partitions.each do |p|
149
+ ref = Rdkafka::Bindings.rd_kafka_topic_partition_list_add(
153
150
  tpl,
154
151
  topic,
155
- p.partition,
156
- offset
152
+ p.partition
157
153
  )
154
+
155
+ if p.metadata
156
+ part = Rdkafka::Bindings::TopicPartition.new(ref)
157
+ str_ptr = FFI::MemoryPointer.from_string(p.metadata)
158
+ # The metadata string is owned by librdkafka once handed over and released here:
159
+ # https://github.com/confluentinc/librdkafka/blob/e03d3bb91ed92a38f38d9806b8d8deffe78a1de5/src/rdkafka_partition.c#L2682C18-L2682C18
160
+ str_ptr.autorelease = false
161
+ part[:metadata] = str_ptr
162
+ part[:metadata_size] = p.metadata.bytesize
163
+ end
164
+
165
+ if p.offset
166
+ offset = p.offset.is_a?(Time) ? p.offset.to_f * 1_000 : p.offset
167
+
168
+ Rdkafka::Bindings.rd_kafka_topic_partition_list_set_offset(
169
+ tpl,
170
+ topic,
171
+ p.partition,
172
+ offset
173
+ )
174
+ end
158
175
  end
176
+ else
177
+ Rdkafka::Bindings.rd_kafka_topic_partition_list_add(
178
+ tpl,
179
+ topic,
180
+ Rdkafka::Bindings::RD_KAFKA_PARTITION_UA
181
+ )
159
182
  end
160
- else
161
- Rdkafka::Bindings.rd_kafka_topic_partition_list_add(
162
- tpl,
163
- topic,
164
- Rdkafka::Bindings::RD_KAFKA_PARTITION_UA
165
- )
166
183
  end
184
+ rescue Exception
185
+ # The native list is caller-owned until it is handed to librdkafka. If population raises
186
+ # partway (e.g. an invalid partition), destroy it here so it is not leaked.
187
+ Rdkafka::Bindings.rd_kafka_topic_partition_list_destroy(tpl)
188
+ raise
167
189
  end
168
190
 
169
191
  tpl
@@ -14,14 +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
19
20
  # @param native_kafka [NativeKafka] wrapper around the native Kafka consumer handle
20
21
  def initialize(native_kafka)
21
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
22
53
 
23
- # Makes sure, that native kafka gets closed before it gets GCed by Ruby
24
- ObjectSpace.define_finalizer(self, native_kafka.finalizer)
54
+ native_kafka.close
55
+ end
25
56
  end
26
57
 
27
58
  # Starts the native Kafka polling thread and kicks off the init polling
@@ -187,6 +218,7 @@ module Rdkafka
187
218
  if @consumer_queue
188
219
  Rdkafka::Bindings.rd_kafka_queue_destroy(@consumer_queue)
189
220
  @consumer_queue = nil
221
+ @consumer_queue_holder[0] = nil
190
222
  end
191
223
  end
192
224
 
@@ -422,6 +454,8 @@ module Rdkafka
422
454
  #
423
455
  # @raise [RdkafkaError] When getting the positions fails.
424
456
  def position(list = nil)
457
+ closed_consumer_check(__method__)
458
+
425
459
  if list.nil?
426
460
  list = assignment
427
461
  elsif !list.is_a?(TopicPartitionList)
@@ -490,8 +524,7 @@ module Rdkafka
490
524
  out = {}
491
525
 
492
526
  topic_partition_list.to_h.each do |topic, partitions|
493
- # Query high watermarks for this topic's partitions
494
- # 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.
495
528
  topic_out = {}
496
529
  partitions.each do |p|
497
530
  next if p.offset.nil?
@@ -509,11 +542,12 @@ module Rdkafka
509
542
 
510
543
  # Returns the ClusterId as reported in broker metadata.
511
544
  #
545
+ # @param timeout_ms [Integer] timeout in milliseconds to wait for the cluster id
512
546
  # @return [String, nil]
513
- def cluster_id
547
+ def cluster_id(timeout_ms = Defaults::CONSUMER_CLUSTER_ID_TIMEOUT_MS)
514
548
  closed_consumer_check(__method__)
515
549
  @native_kafka.with_inner do |inner|
516
- Rdkafka::Bindings.rd_kafka_clusterid(inner)
550
+ read_and_free_native_string(inner, Rdkafka::Bindings.rd_kafka_clusterid(inner, timeout_ms))
517
551
  end
518
552
  end
519
553
 
@@ -525,7 +559,7 @@ module Rdkafka
525
559
  def member_id
526
560
  closed_consumer_check(__method__)
527
561
  @native_kafka.with_inner do |inner|
528
- Rdkafka::Bindings.rd_kafka_memberid(inner)
562
+ read_and_free_native_string(inner, Rdkafka::Bindings.rd_kafka_memberid(inner))
529
563
  end
530
564
  end
531
565
 
@@ -534,16 +568,34 @@ module Rdkafka
534
568
  # When using this `enable.auto.offset.store` should be set to `false` in the config.
535
569
  #
536
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
537
572
  # @return [nil]
538
573
  # @raise [RdkafkaError] When storing the offset fails
539
- def store_offset(message)
574
+ def store_offset(message, metadata = nil)
540
575
  closed_consumer_check(__method__)
541
576
 
542
577
  list = TopicPartitionList.new
543
- list.add_topic_and_partitions_with_offsets(
544
- message.topic,
545
- message.partition => message.offset + 1
546
- )
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
547
599
 
548
600
  tpl = list.to_native_tpl
549
601
 
@@ -572,8 +624,7 @@ module Rdkafka
572
624
  end
573
625
 
574
626
  # Seek to a particular message by providing the topic, partition and offset.
575
- # The next poll on the topic/partition will return the
576
- # message at the given offset.
627
+ # The next poll on the topic/partition will return the message at the given offset.
577
628
  #
578
629
  # @param topic [String] The topic in which to seek
579
630
  # @param partition [Integer] The partition number to seek
@@ -641,8 +692,7 @@ module Rdkafka
641
692
 
642
693
  # Manually commit the current offsets of this consumer.
643
694
  #
644
- # To use this set `enable.auto.commit`to `false` to disable automatic triggering
645
- # of commits.
695
+ # To use this set `enable.auto.commit`to `false` to disable automatic triggering of commits.
646
696
  #
647
697
  # If `enable.auto.offset.store` is set to `true` the offset of the last consumed
648
698
  # message for every partition is used. If set to `false` you can use {store_offset} to
@@ -795,16 +845,21 @@ module Rdkafka
795
845
  # is available, librdkafka fills the buffer with whatever is immediately ready and
796
846
  # returns without further waiting.
797
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
+ #
798
854
  # @param timeout_ms [Integer] Timeout waiting for the first message (-1 for infinite)
799
855
  # @param max_items [Integer] Maximum number of messages to return per call
800
- # @return [Array<Message>] Array of messages (empty if none available within timeout)
801
- # @raise [RdkafkaError] When a consumed message contains an error
856
+ # @return [Array<Message, RdkafkaError>] Batch of messages and/or error events in arrival order
802
857
  # @raise [ClosedConsumerError] When called on a closed consumer
803
858
  def poll_batch(timeout_ms, max_items: 100)
804
859
  closed_consumer_check(__method__)
805
860
 
806
861
  buffer = batch_buffer(max_items)
807
- messages = []
862
+ results = []
808
863
 
809
864
  count = @native_kafka.with_inner do |_inner|
810
865
  Rdkafka::Bindings.rd_kafka_consume_batch_queue(
@@ -815,7 +870,7 @@ module Rdkafka
815
870
  )
816
871
  end
817
872
 
818
- return messages if count <= 0
873
+ return results if count <= 0
819
874
 
820
875
  i = 0
821
876
  begin
@@ -830,11 +885,23 @@ module Rdkafka
830
885
  native_message = Rdkafka::Bindings::Message.new(ptr)
831
886
 
832
887
  if native_message[:err] != Rdkafka::Bindings::RD_KAFKA_RESP_ERR_NO_ERROR
833
- raise Rdkafka::RdkafkaError.new(native_message[:err])
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)
834
903
  end
835
904
 
836
- messages << Rdkafka::Consumer::Message.new(native_message)
837
- Rdkafka::Bindings.rd_kafka_message_destroy(ptr)
838
905
  i += 1
839
906
  end
840
907
  ensure
@@ -845,7 +912,7 @@ module Rdkafka
845
912
  end
846
913
  end
847
914
 
848
- messages
915
+ results
849
916
  end
850
917
 
851
918
  # Poll for a batch of messages without releasing the GVL (Global VM Lock).
@@ -857,16 +924,17 @@ module Rdkafka
857
924
  # @note Since the GVL is not released, a non-zero timeout_ms will block all Ruby
858
925
  # threads/fibers for the duration. Use {#poll_batch} if you need a blocking wait.
859
926
  #
927
+ # Error events are returned inline as {RdkafkaError} objects; see {#poll_batch} for details.
928
+ #
860
929
  # @param timeout_ms [Integer] Timeout waiting for the first message (default: 0 for non-blocking)
861
930
  # @param max_items [Integer] Maximum number of messages to return per call
862
- # @return [Array<Message>] Array of messages (empty if none available within timeout)
863
- # @raise [RdkafkaError] When a consumed message contains an error
931
+ # @return [Array<Message, RdkafkaError>] Batch of messages and/or error events in arrival order
864
932
  # @raise [ClosedConsumerError] When called on a closed consumer
865
933
  def poll_batch_nb(timeout_ms = 0, max_items: 100)
866
934
  closed_consumer_check(__method__)
867
935
 
868
936
  buffer = batch_buffer(max_items)
869
- messages = []
937
+ results = []
870
938
 
871
939
  count = @native_kafka.with_inner do |_inner|
872
940
  Rdkafka::Bindings.rd_kafka_consume_batch_queue_nb(
@@ -877,7 +945,7 @@ module Rdkafka
877
945
  )
878
946
  end
879
947
 
880
- return messages if count <= 0
948
+ return results if count <= 0
881
949
 
882
950
  i = 0
883
951
  begin
@@ -892,11 +960,23 @@ module Rdkafka
892
960
  native_message = Rdkafka::Bindings::Message.new(ptr)
893
961
 
894
962
  if native_message[:err] != Rdkafka::Bindings::RD_KAFKA_RESP_ERR_NO_ERROR
895
- raise Rdkafka::RdkafkaError.new(native_message[:err])
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)
896
978
  end
897
979
 
898
- messages << Rdkafka::Consumer::Message.new(native_message)
899
- Rdkafka::Bindings.rd_kafka_message_destroy(ptr)
900
980
  i += 1
901
981
  end
902
982
  ensure
@@ -907,7 +987,7 @@ module Rdkafka
907
987
  end
908
988
  end
909
989
 
910
- messages
990
+ results
911
991
  end
912
992
 
913
993
  # Poll for new messages and yield for each received one. Iteration
@@ -982,12 +1062,34 @@ module Rdkafka
982
1062
  def closed_consumer_check(method)
983
1063
  raise Rdkafka::ClosedConsumerError.new(method) if closed?
984
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
985
1083
 
986
1084
  # Returns the consumer queue pointer, lazily initialized
987
1085
  # @return [FFI::Pointer] consumer queue handle
988
1086
  def consumer_queue
989
1087
  @consumer_queue ||= @native_kafka.with_inner do |inner|
990
- Rdkafka::Bindings.rd_kafka_queue_get_consumer(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
991
1093
  end
992
1094
  end
993
1095
 
@@ -23,6 +23,10 @@ module Rdkafka
23
23
  # @see Consumer#committed
24
24
  CONSUMER_COMMITTED_TIMEOUT_MS = 2_000
25
25
 
26
+ # Default timeout for fetching the cluster id
27
+ # @see Consumer#cluster_id
28
+ CONSUMER_CLUSTER_ID_TIMEOUT_MS = 1_000
29
+
26
30
  # Default timeout for querying watermark offsets
27
31
  # @see Consumer#query_watermark_offsets
28
32
  CONSUMER_QUERY_WATERMARK_TIMEOUT_MS = 1_000
@@ -91,6 +95,16 @@ module Rdkafka
91
95
  # @see Metadata#initialize
92
96
  METADATA_RETRY_BACKOFF_BASE_MS = 100
93
97
 
98
+ # Maximum backoff time between metadata retries. Caps the exponential backoff so a long retry
99
+ # sequence against an unhealthy cluster cannot block the calling thread for minutes.
100
+ # @see Metadata#initialize
101
+ METADATA_RETRY_BACKOFF_MAX_MS = 1_000
102
+
103
+ # Soft wall-clock budget for the whole metadata retry loop; past it (and past
104
+ # METADATA_MIN_ATTEMPTS) the loop stops so a synchronous fetch cannot block the caller for long
105
+ # @see Metadata#initialize
106
+ METADATA_RETRY_BUDGET_MS = 5_000
107
+
94
108
  # Cache settings (in milliseconds)
95
109
 
96
110
  # Default time-to-live for cached partition counts
@@ -102,5 +116,10 @@ module Rdkafka
102
116
  # Maximum number of metadata fetch retry attempts
103
117
  # @see Metadata#initialize
104
118
  METADATA_MAX_RETRIES = 10
119
+
120
+ # Minimum metadata fetch attempts before the retry budget may end the loop, so a slow broker
121
+ # (whose requests each consume the full timeout) still gets a few tries
122
+ # @see Metadata#initialize
123
+ METADATA_MIN_ATTEMPTS = 3
105
124
  end
106
125
  end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rdkafka
4
+ module Helpers
5
+ # Shared `#metadata` implementation for Admin, Consumer and Producer.
6
+ #
7
+ # `rd_kafka_metadata()` is handle-agnostic in librdkafka - it works on any `rd_kafka_t`
8
+ # (consumer, producer or admin) - so this Ruby-level implementation is identical across all
9
+ # three client types. Includers must provide a private `#closed_check(method)` that raises
10
+ # their own `Closed*Error`.
11
+ module Metadata
12
+ # Performs the metadata request using this client
13
+ #
14
+ # @param topic_name [String, nil] metadata about particular topic or all if nil
15
+ # @param timeout_ms [Integer] metadata request timeout
16
+ # @return [Rdkafka::Metadata] requested metadata
17
+ def metadata(topic_name = nil, timeout_ms = Defaults::METADATA_TIMEOUT_MS)
18
+ closed_check(__method__)
19
+
20
+ @native_kafka.with_inner do |inner|
21
+ # Must stay fully qualified: this module is itself named `Metadata`, so a bare
22
+ # `Metadata.new` here would resolve to `Rdkafka::Helpers::Metadata` (no `.new`) instead
23
+ # of this class.
24
+ Rdkafka::Metadata.new(inner, topic_name, timeout_ms)
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -23,12 +23,57 @@ module Rdkafka
23
23
  # @param timeout_ms [Integer] timeout in milliseconds
24
24
  # @raise [RdkafkaError] when metadata fetch fails
25
25
  def initialize(native_client, topic_name = nil, timeout_ms = Defaults::METADATA_TIMEOUT_MS)
26
- attempt ||= 0
27
- attempt += 1
28
-
29
- native_topic = if topic_name
30
- Rdkafka::Bindings.rd_kafka_topic_new(native_client, topic_name, nil)
26
+ attempt = 0
27
+ deadline = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) +
28
+ Defaults::METADATA_RETRY_BUDGET_MS / 1_000.0
29
+
30
+ begin
31
+ attempt += 1
32
+ fetch_metadata(native_client, topic_name, timeout_ms)
33
+ rescue ::Rdkafka::RdkafkaError => e
34
+ raise unless RETRIED_ERRORS.include?(e.code)
35
+ raise if attempt > Defaults::METADATA_MAX_RETRIES
36
+
37
+ # Stop once the wall-clock retry budget is spent, but only after at least
38
+ # METADATA_MIN_ATTEMPTS tries so a slow broker (whose requests each consume the full
39
+ # timeout) still gets a few tries rather than being cut off after one or two.
40
+ raise if attempt >= Defaults::METADATA_MIN_ATTEMPTS &&
41
+ ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) >= deadline
42
+
43
+ # Exponential backoff between attempts, capped so a long retry sequence cannot block for
44
+ # minutes. The request timeout (`timeout_ms`) is intentionally left unchanged: it used to be
45
+ # overwritten with the backoff value, which shrank the first retries below the configured
46
+ # timeout (near-guaranteeing another timeout) and then inflated later ones to ~100s.
47
+ backoff_ms = [
48
+ (2**attempt) * Defaults::METADATA_RETRY_BACKOFF_BASE_MS,
49
+ Defaults::METADATA_RETRY_BACKOFF_MAX_MS
50
+ ].min
51
+
52
+ sleep(backoff_ms / 1_000.0)
53
+
54
+ retry
31
55
  end
56
+ end
57
+
58
+ private
59
+
60
+ # Performs a single metadata fetch attempt, freeing this attempt's native resources.
61
+ #
62
+ # Kept separate from {#initialize} so each retried attempt frees its own `native_topic` and
63
+ # metadata struct. `retry` re-enters `initialize`'s `begin` without running an `ensure` placed
64
+ # there, so doing the cleanup per attempt here is what prevents the per-retry native leak. The
65
+ # metadata struct is only allocated on success, so it is read and destroyed only after the
66
+ # result has been validated (avoids destroying a NULL/garbage pointer on a failed fetch).
67
+ #
68
+ # @param native_client [FFI::Pointer] pointer to the native Kafka client
69
+ # @param topic_name [String, nil] specific topic to fetch metadata for, or nil for all topics
70
+ # @param timeout_ms [Integer] timeout in milliseconds
71
+ # @raise [RdkafkaError] when the metadata fetch fails
72
+ def fetch_metadata(native_client, topic_name, timeout_ms)
73
+ native_topic = nil
74
+ metadata_ptr = nil
75
+
76
+ native_topic = Rdkafka::Bindings.rd_kafka_topic_new(native_client, topic_name, nil) if topic_name
32
77
 
33
78
  ptr = FFI::MemoryPointer.new(:pointer)
34
79
 
@@ -42,24 +87,16 @@ module Rdkafka
42
87
  # Error Handling
43
88
  raise Rdkafka::RdkafkaError.new(result) unless result.zero?
44
89
 
45
- metadata_from_native(ptr.read_pointer)
46
- rescue ::Rdkafka::RdkafkaError => e
47
- raise unless RETRIED_ERRORS.include?(e.code)
48
- raise if attempt > Defaults::METADATA_MAX_RETRIES
90
+ # rd_kafka_metadata only allocates the struct on success, so we read the pointer to destroy
91
+ # only after the result has been confirmed successful.
92
+ metadata_ptr = ptr.read_pointer
49
93
 
50
- backoff_factor = 2**attempt
51
- timeout_ms = backoff_factor * Defaults::METADATA_RETRY_BACKOFF_BASE_MS
52
-
53
- sleep(timeout_ms / 1000.0)
54
-
55
- retry
94
+ metadata_from_native(metadata_ptr)
56
95
  ensure
57
- Rdkafka::Bindings.rd_kafka_topic_destroy(native_topic) if topic_name
58
- Rdkafka::Bindings.rd_kafka_metadata_destroy(ptr.read_pointer)
96
+ Rdkafka::Bindings.rd_kafka_topic_destroy(native_topic) if native_topic
97
+ Rdkafka::Bindings.rd_kafka_metadata_destroy(metadata_ptr) if metadata_ptr && !metadata_ptr.null?
59
98
  end
60
99
 
61
- private
62
-
63
100
  # Extracts metadata from native pointer
64
101
  # @param ptr [FFI::Pointer] pointer to native metadata
65
102
  def metadata_from_native(ptr)
@@ -134,6 +171,36 @@ module Rdkafka
134
171
  :replicas, :pointer,
135
172
  :in_sync_replica_brokers, :int,
136
173
  :isrs, :pointer
174
+
175
+ # The base `#to_h` skips FFI pointer members, which would drop the replica and in-sync
176
+ # replica assignments entirely. We dereference those pointers here so the partition hash
177
+ # exposes the broker ids backing the partition (needed e.g. to plan replication changes).
178
+ #
179
+ # @return [Hash{Symbol => Integer, Array<Integer>}] partition metadata:
180
+ # * +:partition_id+ (Integer) - partition id
181
+ # * +:leader+ (Integer) - broker id of the partition leader
182
+ # * +:replica_count+ (Integer) - number of assigned replicas
183
+ # * +:in_sync_replica_brokers+ (Integer) - number of in-sync replicas
184
+ # * +:replicas+ (Array<Integer>) - broker ids of the assigned replicas
185
+ # * +:isrs+ (Array<Integer>) - broker ids of the in-sync replicas
186
+ def to_h
187
+ super.merge(
188
+ replicas: read_broker_ids(self[:replicas], self[:replica_count]),
189
+ isrs: read_broker_ids(self[:isrs], self[:in_sync_replica_brokers])
190
+ )
191
+ end
192
+
193
+ private
194
+
195
+ # Reads `count` broker ids (int32) from a replicas/isrs pointer.
196
+ # @param pointer [FFI::Pointer] pointer to the broker ids array
197
+ # @param count [Integer] number of broker ids to read
198
+ # @return [Array<Integer>] broker ids (empty when there are none)
199
+ def read_broker_ids(pointer, count)
200
+ return [] if count.zero? || pointer.null?
201
+
202
+ pointer.read_array_of_int32(count)
203
+ end
137
204
  end
138
205
  end
139
206
  end
@@ -55,8 +55,7 @@ module Rdkafka
55
55
  Rdkafka::Bindings.rd_kafka_poll(@inner, 0)
56
56
 
57
57
  if @run_polling_thread
58
- # Start thread to poll client for delivery callbacks,
59
- # not used in consumer.
58
+ # Start thread to poll client for delivery callbacks, not used in consumer.
60
59
  @polling_thread = Thread.new do
61
60
  loop do
62
61
  @poll_mutex.synchronize do
@@ -197,8 +196,7 @@ module Rdkafka
197
196
  # Indicate to polling thread that we're closing
198
197
  @polling_thread[:closing] = true
199
198
 
200
- # Wait for the polling thread to finish up,
201
- # this can be aborted in practice if this
199
+ # Wait for the polling thread to finish up, this can be aborted in practice if this
202
200
  # code runs from a finalizer.
203
201
  @polling_thread.join
204
202
  end
@@ -8,14 +8,14 @@ module Rdkafka
8
8
  layout :pending, :bool,
9
9
  :response, :int,
10
10
  :partition, :int,
11
- :offset, :int64,
12
- :topic_name, :pointer
11
+ :offset, :int64
13
12
 
14
13
  # @return [Object, nil] label set during message production or nil by default
15
14
  attr_accessor :label
16
15
 
17
16
  # @return [String] topic where we are trying to send the message
18
- # We use this instead of reading from `topic_name` pointer to save on memory allocations
17
+ # Set in `#produce`, where the topic is known upfront. Keeping it as a Ruby attribute
18
+ # spares a per-message native string copy in the delivery callback.
19
19
  attr_accessor :topic
20
20
 
21
21
  # @return [String] the name of the operation (e.g. "delivery")