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
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rdkafka
4
+ # Default timeout and timing values used throughout rdkafka-ruby.
5
+ #
6
+ # All timeout values can be overridden per-call via method parameters.
7
+ # These constants provide a central place to understand and reference
8
+ # the default values used across the library.
9
+ #
10
+ # @note These are rdkafka-ruby defaults, not librdkafka configuration options.
11
+ # For librdkafka options, see:
12
+ # https://github.com/confluentinc/librdkafka/blob/master/CONFIGURATION.md
13
+ #
14
+ # @example Overriding a timeout per-call
15
+ # consumer.committed(timeout_ms: 5_000) # Use 5 seconds instead of default 2 seconds
16
+ #
17
+ # @example Checking the default value
18
+ # Rdkafka::Defaults::CONSUMER_COMMITTED_TIMEOUT_MS # => 2000
19
+ module Defaults
20
+ # Consumer timeouts (in milliseconds)
21
+
22
+ # Default timeout for fetching committed offsets
23
+ # @see Consumer#committed
24
+ CONSUMER_COMMITTED_TIMEOUT_MS = 2_000
25
+
26
+ # Default timeout for fetching the cluster id
27
+ # @see Consumer#cluster_id
28
+ CONSUMER_CLUSTER_ID_TIMEOUT_MS = 1_000
29
+
30
+ # Default timeout for querying watermark offsets
31
+ # @see Consumer#query_watermark_offsets
32
+ CONSUMER_QUERY_WATERMARK_TIMEOUT_MS = 1_000
33
+
34
+ # Default timeout for lag calculation watermark queries
35
+ # @see Consumer#lag
36
+ CONSUMER_LAG_TIMEOUT_MS = 1_000
37
+
38
+ # Default timeout for offsets_for_times operation
39
+ # @see Consumer#offsets_for_times
40
+ CONSUMER_OFFSETS_FOR_TIMES_TIMEOUT_MS = 1_000
41
+
42
+ # Default poll timeout for Consumer#each iterator
43
+ # @see Consumer#each
44
+ CONSUMER_POLL_TIMEOUT_MS = 250
45
+
46
+ # Seek operation timeout (0 = non-blocking)
47
+ # @see Consumer#seek_by
48
+ CONSUMER_SEEK_TIMEOUT_MS = 0
49
+
50
+ # Events poll timeout (0 = non-blocking/async)
51
+ # @see Consumer#events_poll
52
+ CONSUMER_EVENTS_POLL_TIMEOUT_MS = 0
53
+
54
+ # Producer timeouts (in milliseconds)
55
+
56
+ # Default timeout for producer flush operation
57
+ # @see Producer#flush
58
+ PRODUCER_FLUSH_TIMEOUT_MS = 5_000
59
+
60
+ # Default flush timeout during purge operation
61
+ # @see Producer#purge
62
+ PRODUCER_PURGE_FLUSH_TIMEOUT_MS = 100
63
+
64
+ # Metadata timeouts (in milliseconds)
65
+
66
+ # Default timeout for metadata requests
67
+ # @see Admin#metadata
68
+ # @see Metadata#initialize
69
+ METADATA_TIMEOUT_MS = 2_000
70
+
71
+ # Handle wait timeouts (in milliseconds)
72
+
73
+ # Default maximum wait timeout for async handles (delivery, admin operations)
74
+ # @see AbstractHandle#wait
75
+ HANDLE_WAIT_TIMEOUT_MS = 60_000
76
+
77
+ # Native Kafka polling (in milliseconds)
78
+
79
+ # Default poll timeout for producer/admin native polling thread
80
+ # @see Config#producer
81
+ # @see Config#admin
82
+ NATIVE_KAFKA_POLL_TIMEOUT_MS = 100
83
+
84
+ # Internal timing (in milliseconds)
85
+
86
+ # Sleep interval during purge wait loop
87
+ # @see Producer#purge
88
+ PRODUCER_PURGE_SLEEP_INTERVAL_MS = 1
89
+
90
+ # Sleep interval while waiting for operations to complete in NativeKafka#synchronize
91
+ # @see NativeKafka#synchronize
92
+ NATIVE_KAFKA_SYNCHRONIZE_SLEEP_INTERVAL_MS = 10
93
+
94
+ # Base backoff factor for metadata retry in milliseconds (multiplied by 2^attempt)
95
+ # @see Metadata#initialize
96
+ METADATA_RETRY_BACKOFF_BASE_MS = 100
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
+
108
+ # Cache settings (in milliseconds)
109
+
110
+ # Default time-to-live for cached partition counts
111
+ # @see Producer::PartitionsCountCache
112
+ PARTITIONS_COUNT_CACHE_TTL_MS = 30_000
113
+
114
+ # Configuration values (not time-based)
115
+
116
+ # Maximum number of metadata fetch retry attempts
117
+ # @see Metadata#initialize
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
124
+ end
125
+ end
data/lib/rdkafka/error.rb CHANGED
@@ -18,12 +18,21 @@ module Rdkafka
18
18
  # @return [String]
19
19
  attr_reader :broker_message
20
20
 
21
+ # The name of the rdkafka instance that generated this error
22
+ # @return [String, nil]
23
+ attr_reader :instance_name
24
+
21
25
  # @private
22
- def initialize(response, message_prefix=nil, broker_message: nil)
26
+ # @param response [Integer] the raw error response code from librdkafka
27
+ # @param message_prefix [String, nil] optional prefix for error messages
28
+ # @param broker_message [String, nil] optional error message from the broker
29
+ # @param instance_name [String, nil] optional name of the rdkafka instance
30
+ def initialize(response, message_prefix = nil, broker_message: nil, instance_name: nil)
23
31
  raise TypeError.new("Response has to be an integer") unless response.is_a? Integer
24
32
  @rdkafka_response = response
25
33
  @message_prefix = message_prefix
26
34
  @broker_message = broker_message
35
+ @instance_name = instance_name
27
36
  end
28
37
 
29
38
  # This error's code, for example `:partition_eof`, `:msg_size_too_large`.
@@ -31,7 +40,7 @@ module Rdkafka
31
40
  def code
32
41
  code = Rdkafka::Bindings.rd_kafka_err2name(@rdkafka_response).downcase
33
42
  if code[0] == "_"
34
- code[1..-1].to_sym
43
+ code[1..].to_sym
35
44
  else
36
45
  code.to_sym
37
46
  end
@@ -41,11 +50,16 @@ module Rdkafka
41
50
  # @return [String]
42
51
  def to_s
43
52
  message_prefix_part = if message_prefix
44
- "#{message_prefix} - "
45
- else
46
- ''
47
- end
48
- "#{message_prefix_part}#{Rdkafka::Bindings.rd_kafka_err2str(@rdkafka_response)} (#{code})"
53
+ "#{message_prefix} - "
54
+ else
55
+ ""
56
+ end
57
+ instance_name_part = if instance_name
58
+ " [#{instance_name}]"
59
+ else
60
+ ""
61
+ end
62
+ "#{message_prefix_part}#{Rdkafka::Bindings.rd_kafka_err2str(@rdkafka_response)} (#{code})#{instance_name_part}"
49
63
  end
50
64
 
51
65
  # Whether this error indicates the partition is EOF.
@@ -55,8 +69,10 @@ module Rdkafka
55
69
  end
56
70
 
57
71
  # Error comparison
58
- def ==(another_error)
59
- another_error.is_a?(self.class) && (self.to_s == another_error.to_s)
72
+ # @param other [Object] object to compare with
73
+ # @return [Boolean]
74
+ def ==(other)
75
+ other.is_a?(self.class) && (to_s == other.to_s)
60
76
  end
61
77
  end
62
78
 
@@ -66,7 +82,10 @@ module Rdkafka
66
82
  attr_reader :topic_partition_list
67
83
 
68
84
  # @private
69
- def initialize(response, topic_partition_list, message_prefix=nil)
85
+ # @param response [Integer] the raw error response code from librdkafka
86
+ # @param topic_partition_list [TopicPartitionList] the topic partition list with error info
87
+ # @param message_prefix [String, nil] optional prefix for error messages
88
+ def initialize(response, topic_partition_list, message_prefix = nil)
70
89
  super(response, message_prefix)
71
90
  @topic_partition_list = topic_partition_list
72
91
  end
@@ -74,28 +93,35 @@ module Rdkafka
74
93
 
75
94
  # Error class for public consumer method calls on a closed consumer.
76
95
  class ClosedConsumerError < BaseError
96
+ # @param method [Symbol] the method that was called
77
97
  def initialize(method)
78
- super("Illegal call to #{method.to_s} on a closed consumer")
98
+ super("Illegal call to #{method} on a closed consumer")
79
99
  end
80
100
  end
81
101
 
82
102
  # Error class for public producer method calls on a closed producer.
83
103
  class ClosedProducerError < BaseError
104
+ # @param method [Symbol] the method that was called
84
105
  def initialize(method)
85
- super("Illegal call to #{method.to_s} on a closed producer")
106
+ super("Illegal call to #{method} on a closed producer")
86
107
  end
87
108
  end
88
109
 
89
- # Error class for public consumer method calls on a closed admin.
110
+ # Error class for public admin method calls on a closed admin.
90
111
  class ClosedAdminError < BaseError
112
+ # @param method [Symbol] the method that was called
91
113
  def initialize(method)
92
- super("Illegal call to #{method.to_s} on a closed admin")
114
+ super("Illegal call to #{method} on a closed admin")
93
115
  end
94
116
  end
95
117
 
118
+ # Error class for calls on a closed inner librdkafka instance.
96
119
  class ClosedInnerError < BaseError
97
120
  def initialize
98
121
  super("Illegal call to a closed inner librdkafka instance")
99
122
  end
100
123
  end
124
+
125
+ # Error class for librdkafka library loading failures (e.g., glibc compatibility issues).
126
+ class LibraryLoadError < BaseError; end
101
127
  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
@@ -1,8 +1,7 @@
1
1
  module Rdkafka
2
2
  module Helpers
3
-
3
+ # OAuth helper methods for setting and refreshing SASL/OAUTHBEARER tokens
4
4
  module OAuth
5
-
6
5
  # Set the OAuthBearer token
7
6
  #
8
7
  # @param token [String] the mandatory token value to set, often (but not necessarily) a JWS compact serialization as per https://tools.ietf.org/html/rfc7515#section-3.1.
@@ -12,12 +11,18 @@ module Rdkafka
12
11
  # @return [Integer] 0 on success
13
12
  def oauthbearer_set_token(token:, lifetime_ms:, principal_name:, extensions: nil)
14
13
  error_buffer = FFI::MemoryPointer.from_string(" " * 256)
14
+ extensions_ptr, extensions_str_ptrs = map_extensions(extensions)
15
15
 
16
- response = @native_kafka.with_inner do |inner|
17
- Rdkafka::Bindings.rd_kafka_oauthbearer_set_token(
18
- inner, token, lifetime_ms, principal_name,
19
- flatten_extensions(extensions), extension_size(extensions), error_buffer, 256
20
- )
16
+ begin
17
+ response = @native_kafka.with_inner do |inner|
18
+ Rdkafka::Bindings.rd_kafka_oauthbearer_set_token(
19
+ inner, token, lifetime_ms, principal_name,
20
+ extensions_ptr, extension_size(extensions), error_buffer, 256
21
+ )
22
+ end
23
+ ensure
24
+ extensions_str_ptrs&.each { |ptr| ptr.free }
25
+ extensions_ptr&.free
21
26
  end
22
27
 
23
28
  return response if response.zero?
@@ -41,14 +46,41 @@ module Rdkafka
41
46
 
42
47
  private
43
48
 
44
- # Flatten the extensions hash into a string according to the spec, https://datatracker.ietf.org/doc/html/rfc7628#section-3.1
45
- def flatten_extensions(extensions)
46
- return nil unless extensions
47
- "\x01#{extensions.map { |e| e.join("=") }.join("\x01")}"
49
+ # Convert extensions hash to FFI::MemoryPointer (`const char **`).
50
+ #
51
+ # @param extensions [Hash, nil] extension key-value pairs
52
+ # @return [Array<FFI::MemoryPointer, Array<FFI::MemoryPointer>>] array pointer and string pointers
53
+ # @note The returned pointers must be freed manually (autorelease = false).
54
+ def map_extensions(extensions)
55
+ return [nil, nil] if extensions.nil? || extensions.empty?
56
+
57
+ # https://github.com/confluentinc/librdkafka/blob/master/src/rdkafka_sasl_oauthbearer.c#L327-L347
58
+
59
+ # The method argument is const char **
60
+ array_ptr = FFI::MemoryPointer.new(:pointer, extension_size(extensions))
61
+ array_ptr.autorelease = false
62
+ str_ptrs = []
63
+
64
+ # Element i is the key, i + 1 is the value.
65
+ extensions.each_with_index do |(k, v), i|
66
+ k_ptr = FFI::MemoryPointer.from_string(k.to_s)
67
+ k_ptr.autorelease = false
68
+ str_ptrs << k_ptr
69
+ v_ptr = FFI::MemoryPointer.from_string(v.to_s)
70
+ v_ptr.autorelease = false
71
+ str_ptrs << v_ptr
72
+ array_ptr[i * 2].put_pointer(0, k_ptr)
73
+ array_ptr[i * 2 + 1].put_pointer(0, v_ptr)
74
+ end
75
+
76
+ [array_ptr, str_ptrs]
48
77
  end
49
78
 
50
- # extension_size is the number of keys + values which should be a non-negative even number
51
- # https://github.com/confluentinc/librdkafka/blob/master/src/rdkafka_sasl_oauthbearer.c#L327-L347
79
+ # Returns the extension size (number of keys + values).
80
+ #
81
+ # @param extensions [Hash, nil] extension key-value pairs
82
+ # @return [Integer] non-negative even number representing keys + values count
83
+ # @see https://github.com/confluentinc/librdkafka/blob/master/src/rdkafka_sasl_oauthbearer.c#L327-L347
52
84
  def extension_size(extensions)
53
85
  return 0 unless extensions
54
86
  extensions.size * 2
@@ -9,6 +9,11 @@ module Rdkafka
9
9
  def monotonic_now
10
10
  ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
11
11
  end
12
+
13
+ # @return [Integer] current monotonic time in milliseconds
14
+ def monotonic_now_ms
15
+ ::Process.clock_gettime(::Process::CLOCK_MONOTONIC, :millisecond)
16
+ end
12
17
  end
13
18
  end
14
19
  end
@@ -1,8 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Rdkafka
4
+ # Provides cluster metadata information
4
5
  class Metadata
5
- attr_reader :brokers, :topics
6
+ # @return [Array<Hash>] list of broker metadata
7
+ attr_reader :brokers
8
+ # @return [Array<Hash>] list of topic metadata
9
+ attr_reader :topics
6
10
 
7
11
  # Errors upon which we retry the metadata fetch
8
12
  RETRIED_ERRORS = %i[
@@ -12,13 +16,64 @@ module Rdkafka
12
16
 
13
17
  private_constant :RETRIED_ERRORS
14
18
 
15
- def initialize(native_client, topic_name = nil, timeout_ms = 2_000)
16
- attempt ||= 0
17
- attempt += 1
18
-
19
- native_topic = if topic_name
20
- Rdkafka::Bindings.rd_kafka_topic_new(native_client, topic_name, nil)
19
+ # Fetches metadata from the Kafka cluster
20
+ #
21
+ # @param native_client [FFI::Pointer] pointer to the native Kafka client
22
+ # @param topic_name [String, nil] specific topic to fetch metadata for, or nil for all topics
23
+ # @param timeout_ms [Integer] timeout in milliseconds
24
+ # @raise [RdkafkaError] when metadata fetch fails
25
+ def initialize(native_client, topic_name = nil, timeout_ms = Defaults::METADATA_TIMEOUT_MS)
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
21
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
22
77
 
23
78
  ptr = FFI::MemoryPointer.new(:pointer)
24
79
 
@@ -32,24 +87,18 @@ module Rdkafka
32
87
  # Error Handling
33
88
  raise Rdkafka::RdkafkaError.new(result) unless result.zero?
34
89
 
35
- metadata_from_native(ptr.read_pointer)
36
- rescue ::Rdkafka::RdkafkaError => e
37
- raise unless RETRIED_ERRORS.include?(e.code)
38
- raise if attempt > 10
39
-
40
- backoff_factor = 2**attempt
41
- timeout = backoff_factor * 0.1
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
42
93
 
43
- sleep(timeout)
44
-
45
- retry
94
+ metadata_from_native(metadata_ptr)
46
95
  ensure
47
- Rdkafka::Bindings.rd_kafka_topic_destroy(native_topic) if topic_name
48
- 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?
49
98
  end
50
99
 
51
- private
52
-
100
+ # Extracts metadata from native pointer
101
+ # @param ptr [FFI::Pointer] pointer to native metadata
53
102
  def metadata_from_native(ptr)
54
103
  metadata = Metadata.new(ptr)
55
104
  @brokers = Array.new(metadata[:brokers_count]) do |i|
@@ -69,7 +118,11 @@ module Rdkafka
69
118
  end
70
119
  end
71
120
 
121
+ # Base class for metadata FFI structs with hash conversion
122
+ # @private
72
123
  class CustomFFIStruct < FFI::Struct
124
+ # Converts struct to a hash
125
+ # @return [Hash]
73
126
  def to_h
74
127
  members.each_with_object({}) do |mem, hsh|
75
128
  val = self.[](mem)
@@ -80,36 +133,74 @@ module Rdkafka
80
133
  end
81
134
  end
82
135
 
136
+ # @private
137
+ # FFI struct for rd_kafka_metadata_t
83
138
  class Metadata < CustomFFIStruct
84
139
  layout :brokers_count, :int,
85
- :brokers_metadata, :pointer,
86
- :topics_count, :int,
87
- :topics_metadata, :pointer,
88
- :broker_id, :int32,
89
- :broker_name, :string
140
+ :brokers_metadata, :pointer,
141
+ :topics_count, :int,
142
+ :topics_metadata, :pointer,
143
+ :broker_id, :int32,
144
+ :broker_name, :string
90
145
  end
91
146
 
147
+ # @private
148
+ # FFI struct for rd_kafka_metadata_broker_t
92
149
  class BrokerMetadata < CustomFFIStruct
93
150
  layout :broker_id, :int32,
94
- :broker_name, :string,
95
- :broker_port, :int
151
+ :broker_name, :string,
152
+ :broker_port, :int
96
153
  end
97
154
 
155
+ # @private
156
+ # FFI struct for rd_kafka_metadata_topic_t
98
157
  class TopicMetadata < CustomFFIStruct
99
158
  layout :topic_name, :string,
100
- :partition_count, :int,
101
- :partitions_metadata, :pointer,
102
- :rd_kafka_resp_err, :int
159
+ :partition_count, :int,
160
+ :partitions_metadata, :pointer,
161
+ :rd_kafka_resp_err, :int
103
162
  end
104
163
 
164
+ # @private
165
+ # FFI struct for rd_kafka_metadata_partition_t
105
166
  class PartitionMetadata < CustomFFIStruct
106
167
  layout :partition_id, :int32,
107
- :rd_kafka_resp_err, :int,
108
- :leader, :int32,
109
- :replica_count, :int,
110
- :replicas, :pointer,
111
- :in_sync_replica_brokers, :int,
112
- :isrs, :pointer
168
+ :rd_kafka_resp_err, :int,
169
+ :leader, :int32,
170
+ :replica_count, :int,
171
+ :replicas, :pointer,
172
+ :in_sync_replica_brokers, :int,
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
113
204
  end
114
205
  end
115
206
  end