rdkafka 0.29.1 → 0.29.2

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9df14d82195673f4747cc9a5d20eeb705a547295b1fa25f6554a5f9fac415ff0
4
- data.tar.gz: d98858ef1d0c6694acd2de1726e0727a17aae1694adf2d75ee9890bdf8608d57
3
+ metadata.gz: f2acf73116281ea22d96af51b56fecc615cb32616d25834a5a990fae1213a1ba
4
+ data.tar.gz: 04c06f93561b22719261fab54fc0191f1a6edba01926596124f430adcc11d7d5
5
5
  SHA512:
6
- metadata.gz: 82715ad307088ce3559789b9445c968fb1dcbfa2e87ba70b8e90c786ff92066c37497b8592153e8512ed297706a8499d38e2c04754129390665478a3174bcf0e
7
- data.tar.gz: 00fd0221b7afde5cf110437d3b83f7672b820b3ebc596d2ad7126c248f31bb89b35d29ced75c24da0a17af2828a2fc4f7feb4397cdcfe6d04bf78882f2ee02b6
6
+ metadata.gz: 7ea74276532d8cd48ee913a46166497ace6426bd8859eb41d72f5407bc5a1eb4273bbe2d82a793618e3420a956b95a86919e416c46e0ac657d8b8056167dcdc0
7
+ data.tar.gz: 20e3c8ac1bb1113673f5af90e977dc3e59e6e6b9f9b0b550e1f6b1a9796bd19b1915d8ba8e345e5cfb0e574d61e8fe354788e0f3445ee0333a8634c7f81e4d97
data/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Rdkafka Changelog
2
2
 
3
+ ## 0.29.2 (2026-09-11)
4
+ - [Fix] Close live clients from an `at_exit` hook before Ruby's shutdown finalization, so librdkafka is no longer `dlclose`d while its native threads are still running (which could segfault on exit).
5
+ - [Fix] Make client construction exception-safe and destroy the native handle when setup fails after `rd_kafka_new`, so a later error no longer orphans the native client.
6
+ - [Fix] Destroy each polled message exactly once in `Consumer#poll_batch`/`#poll_batch_nb` when building a message raises a non-`RdkafkaError`, fixing a double-free that could abort the process and closing the matching leak window.
7
+ - [Fix] Free the background queue, `AdminOptions` and any already-built `ConfigResource`s (and remove the handle) when `Admin#describe_configs`/`#incremental_alter_configs` raise while building native resources (e.g. a non-String resource name).
8
+ - [Fix] Raise a clear `ConfigError` instead of segfaulting when `Admin#describe_configs`/`#incremental_alter_configs` are given an empty resource name or a negative resource type (`rd_kafka_ConfigResource_new` returns NULL), or when `rd_kafka_AdminOptions_new` returns NULL.
9
+
3
10
  ## 0.29.1 (2026-09-04)
4
11
  - [Enhancement] Add `Admin#delete_records` to delete all messages in a partition up to a given offset.
5
12
  - [Enhancement] Add `Admin#list_consumer_groups` to list the cluster's consumer groups.
@@ -8,6 +15,7 @@
8
15
  - [Fix] Make `NativeKafka#close` fork-aware so a forked child no longer segfaults on exit.
9
16
  - [Fix] Stabilize the `to_native_tpl` leak integration spec.
10
17
  - [Fix] Stabilize the flaky partitions count cache statistics spec.
18
+ - [Fix] Use global allocator in topic_partition_list for pointer freed by librdkafka.
11
19
 
12
20
  ## 0.29.0 (2026-07-10)
13
21
  - [Enhancement] Bump librdkafka to `2.14.2`
data/lib/rdkafka/admin.rb CHANGED
@@ -898,35 +898,64 @@ module Rdkafka
898
898
  handle[:pending] = true
899
899
  handle[:response] = Rdkafka::Bindings::RD_KAFKA_PARTITION_UA
900
900
 
901
- queue_ptr = @native_kafka.with_inner do |inner|
902
- Rdkafka::Bindings.rd_kafka_queue_get_background(inner)
903
- end
901
+ # All native allocation happens inside the begin so a raise at any point (a null background
902
+ # queue, a non-String resource name making `from_string` raise mid-build, etc.) is cleaned up
903
+ # by the ensure rather than leaking the queue, the AdminOptions, the handle and the
904
+ # ConfigResources already built.
905
+ queue_ptr = nil
906
+ admin_options_ptr = nil
907
+ pointer_array = []
908
+ registered = false
904
909
 
905
- if queue_ptr.null?
906
- raise Rdkafka::Config::ConfigError.new("rd_kafka_queue_get_background was NULL")
907
- end
910
+ begin
911
+ queue_ptr = @native_kafka.with_inner do |inner|
912
+ Rdkafka::Bindings.rd_kafka_queue_get_background(inner)
913
+ end
908
914
 
909
- admin_options_ptr = @native_kafka.with_inner do |inner|
910
- Rdkafka::Bindings.rd_kafka_AdminOptions_new(
911
- inner,
912
- Rdkafka::Bindings::RD_KAFKA_ADMIN_OP_DESCRIBECONFIGS
913
- )
914
- end
915
+ if queue_ptr.null?
916
+ raise Rdkafka::Config::ConfigError.new("rd_kafka_queue_get_background was NULL")
917
+ end
915
918
 
916
- DescribeConfigsHandle.register(handle)
917
- Rdkafka::Bindings.rd_kafka_AdminOptions_set_opaque(admin_options_ptr, handle.to_ptr)
919
+ admin_options_ptr = @native_kafka.with_inner do |inner|
920
+ Rdkafka::Bindings.rd_kafka_AdminOptions_new(
921
+ inner,
922
+ Rdkafka::Bindings::RD_KAFKA_ADMIN_OP_DESCRIBECONFIGS
923
+ )
924
+ end
918
925
 
919
- pointer_array = parsed_resources.map do |resource_type, resource_name|
920
- Rdkafka::Bindings.rd_kafka_ConfigResource_new(
921
- resource_type,
922
- FFI::MemoryPointer.from_string(resource_name)
923
- )
924
- end
926
+ # A NULL AdminOptions would segfault the moment we set its opaque pointer, so reject it
927
+ # before registering the handle (the ensure frees the queue already acquired).
928
+ if admin_options_ptr.null?
929
+ raise Rdkafka::Config::ConfigError.new("rd_kafka_AdminOptions_new was NULL")
930
+ end
925
931
 
926
- configs_array_ptr = FFI::MemoryPointer.new(:pointer, pointer_array.size)
927
- configs_array_ptr.write_array_of_pointer(pointer_array)
932
+ DescribeConfigsHandle.register(handle)
933
+ registered = true
934
+ Rdkafka::Bindings.rd_kafka_AdminOptions_set_opaque(admin_options_ptr, handle.to_ptr)
935
+
936
+ # Build resources one at a time so a raise on a later element still leaves the earlier ones
937
+ # in pointer_array for the ensure to destroy.
938
+ parsed_resources.each do |resource_type, resource_name|
939
+ resource_ptr = Rdkafka::Bindings.rd_kafka_ConfigResource_new(
940
+ resource_type,
941
+ FFI::MemoryPointer.from_string(resource_name)
942
+ )
943
+
944
+ # librdkafka returns NULL for an empty resource name or a negative resource type. Passing
945
+ # NULL on to DescribeConfigs and then destroying it both dereference NULL and segfault, so
946
+ # reject it here (the ensure frees everything already built).
947
+ if resource_ptr.null?
948
+ raise Rdkafka::Config::ConfigError.new(
949
+ "rd_kafka_ConfigResource_new was NULL for #{resource_name.inspect} (type #{resource_type})"
950
+ )
951
+ end
952
+
953
+ pointer_array << resource_ptr
954
+ end
955
+
956
+ configs_array_ptr = FFI::MemoryPointer.new(:pointer, pointer_array.size)
957
+ configs_array_ptr.write_array_of_pointer(pointer_array)
928
958
 
929
- begin
930
959
  @native_kafka.with_inner do |inner|
931
960
  Rdkafka::Bindings.rd_kafka_DescribeConfigs(
932
961
  inner,
@@ -937,18 +966,20 @@ module Rdkafka
937
966
  )
938
967
  end
939
968
  rescue Exception
940
- DescribeConfigsHandle.remove(handle.to_ptr.address)
969
+ DescribeConfigsHandle.remove(handle.to_ptr.address) if registered
941
970
 
942
971
  raise
943
972
  ensure
944
- Rdkafka::Bindings.rd_kafka_AdminOptions_destroy(admin_options_ptr)
945
- Rdkafka::Bindings.rd_kafka_queue_destroy(queue_ptr)
973
+ if admin_options_ptr && !admin_options_ptr.null?
974
+ Rdkafka::Bindings.rd_kafka_AdminOptions_destroy(admin_options_ptr)
975
+ end
946
976
 
947
- if configs_array_ptr
948
- Rdkafka::Bindings.rd_kafka_ConfigResource_destroy_array(
949
- configs_array_ptr,
950
- pointer_array.size
951
- )
977
+ Rdkafka::Bindings.rd_kafka_queue_destroy(queue_ptr) if queue_ptr && !queue_ptr.null?
978
+
979
+ pointer_array.each do |config_resource_ptr|
980
+ next if config_resource_ptr.null?
981
+
982
+ Rdkafka::Bindings.rd_kafka_ConfigResource_destroy(config_resource_ptr)
952
983
  end
953
984
  end
954
985
 
@@ -987,66 +1018,91 @@ module Rdkafka
987
1018
  handle[:pending] = true
988
1019
  handle[:response] = Rdkafka::Bindings::RD_KAFKA_PARTITION_UA
989
1020
 
990
- queue_ptr = @native_kafka.with_inner do |inner|
991
- Rdkafka::Bindings.rd_kafka_queue_get_background(inner)
992
- end
1021
+ # All native allocation happens inside the begin so a raise at any point (a null background
1022
+ # queue, a non-String resource name, or FFI marshaling of a config name/value failing while
1023
+ # adding configs) is cleaned up by the ensure rather than leaking the queue, the AdminOptions,
1024
+ # the handle and the ConfigResources already built.
1025
+ queue_ptr = nil
1026
+ admin_options_ptr = nil
1027
+ pointer_array = []
1028
+ registered = false
1029
+ add_error = nil
993
1030
 
994
- if queue_ptr.null?
995
- raise Rdkafka::Config::ConfigError.new("rd_kafka_queue_get_background was NULL")
996
- end
1031
+ begin
1032
+ queue_ptr = @native_kafka.with_inner do |inner|
1033
+ Rdkafka::Bindings.rd_kafka_queue_get_background(inner)
1034
+ end
997
1035
 
998
- admin_options_ptr = @native_kafka.with_inner do |inner|
999
- Rdkafka::Bindings.rd_kafka_AdminOptions_new(
1000
- inner,
1001
- Rdkafka::Bindings::RD_KAFKA_ADMIN_OP_INCREMENTALALTERCONFIGS
1002
- )
1003
- end
1036
+ if queue_ptr.null?
1037
+ raise Rdkafka::Config::ConfigError.new("rd_kafka_queue_get_background was NULL")
1038
+ end
1004
1039
 
1005
- IncrementalAlterConfigsHandle.register(handle)
1006
- Rdkafka::Bindings.rd_kafka_AdminOptions_set_opaque(admin_options_ptr, handle.to_ptr)
1040
+ admin_options_ptr = @native_kafka.with_inner do |inner|
1041
+ Rdkafka::Bindings.rd_kafka_AdminOptions_new(
1042
+ inner,
1043
+ Rdkafka::Bindings::RD_KAFKA_ADMIN_OP_INCREMENTALALTERCONFIGS
1044
+ )
1045
+ end
1007
1046
 
1008
- add_error = nil
1047
+ # A NULL AdminOptions would segfault the moment we set its opaque pointer, so reject it
1048
+ # before registering the handle (the ensure frees the queue already acquired).
1049
+ if admin_options_ptr.null?
1050
+ raise Rdkafka::Config::ConfigError.new("rd_kafka_AdminOptions_new was NULL")
1051
+ end
1009
1052
 
1010
- pointer_array = parsed_resources.map do |resource_type, resource_name, configs|
1011
- # First build the appropriate resource representation
1012
- resource_ptr = Rdkafka::Bindings.rd_kafka_ConfigResource_new(
1013
- resource_type,
1014
- FFI::MemoryPointer.from_string(resource_name)
1015
- )
1053
+ IncrementalAlterConfigsHandle.register(handle)
1054
+ registered = true
1055
+ Rdkafka::Bindings.rd_kafka_AdminOptions_set_opaque(admin_options_ptr, handle.to_ptr)
1016
1056
 
1017
- configs.each do |name, op_type, value|
1018
- # rd_kafka_ConfigResource_add_incremental_config returns a non-NULL rd_kafka_error_t for
1019
- # an invalid op_type, an empty/nil name, or a nil value on a non-delete op. The result
1020
- # used to be ignored: the entry was silently dropped, the alter request still reported
1021
- # success, and the error object leaked. Capture the first error (always destroying the
1022
- # native error object) and raise it below, before the request is sent.
1023
- error_ptr = Bindings.rd_kafka_ConfigResource_add_incremental_config(
1024
- resource_ptr,
1025
- name,
1026
- op_type,
1027
- value
1057
+ parsed_resources.each do |resource_type, resource_name, configs|
1058
+ # First build the appropriate resource representation
1059
+ resource_ptr = Rdkafka::Bindings.rd_kafka_ConfigResource_new(
1060
+ resource_type,
1061
+ FFI::MemoryPointer.from_string(resource_name)
1028
1062
  )
1029
1063
 
1030
- unless error_ptr.null?
1031
- code = Rdkafka::Bindings.rd_kafka_error_code(error_ptr)
1032
- Rdkafka::Bindings.rd_kafka_error_destroy(error_ptr)
1064
+ # librdkafka returns NULL for an empty resource name or a negative resource type. Passing
1065
+ # NULL on to add_incremental_config/IncrementalAlterConfigs and then destroying it both
1066
+ # dereference NULL and segfault, so reject it here (the ensure frees everything built).
1067
+ if resource_ptr.null?
1068
+ raise Rdkafka::Config::ConfigError.new(
1069
+ "rd_kafka_ConfigResource_new was NULL for #{resource_name.inspect} (type #{resource_type})"
1070
+ )
1071
+ end
1033
1072
 
1034
- unless code == Rdkafka::Bindings::RD_KAFKA_RESP_ERR_NO_ERROR
1035
- add_error ||= Rdkafka::RdkafkaError.new(
1036
- code,
1037
- "rd_kafka_ConfigResource_add_incremental_config"
1038
- )
1073
+ # Track it immediately so a raise while adding its configs still frees it via the ensure.
1074
+ pointer_array << resource_ptr
1075
+
1076
+ configs.each do |name, op_type, value|
1077
+ # rd_kafka_ConfigResource_add_incremental_config returns a non-NULL rd_kafka_error_t for
1078
+ # an invalid op_type, an empty/nil name, or a nil value on a non-delete op. The result
1079
+ # used to be ignored: the entry was silently dropped, the alter request still reported
1080
+ # success, and the error object leaked. Capture the first error (always destroying the
1081
+ # native error object) and raise it below, before the request is sent.
1082
+ error_ptr = Bindings.rd_kafka_ConfigResource_add_incremental_config(
1083
+ resource_ptr,
1084
+ name,
1085
+ op_type,
1086
+ value
1087
+ )
1088
+
1089
+ unless error_ptr.null?
1090
+ code = Rdkafka::Bindings.rd_kafka_error_code(error_ptr)
1091
+ Rdkafka::Bindings.rd_kafka_error_destroy(error_ptr)
1092
+
1093
+ unless code == Rdkafka::Bindings::RD_KAFKA_RESP_ERR_NO_ERROR
1094
+ add_error ||= Rdkafka::RdkafkaError.new(
1095
+ code,
1096
+ "rd_kafka_ConfigResource_add_incremental_config"
1097
+ )
1098
+ end
1039
1099
  end
1040
1100
  end
1041
1101
  end
1042
1102
 
1043
- resource_ptr
1044
- end
1045
-
1046
- configs_array_ptr = FFI::MemoryPointer.new(:pointer, pointer_array.size)
1047
- configs_array_ptr.write_array_of_pointer(pointer_array)
1103
+ configs_array_ptr = FFI::MemoryPointer.new(:pointer, pointer_array.size)
1104
+ configs_array_ptr.write_array_of_pointer(pointer_array)
1048
1105
 
1049
- begin
1050
1106
  # Raise only after the full array is built so the ensure below frees every ConfigResource
1051
1107
  # we created, and before the request is sent so a rejected entry can't report success.
1052
1108
  raise add_error if add_error
@@ -1061,18 +1117,20 @@ module Rdkafka
1061
1117
  )
1062
1118
  end
1063
1119
  rescue Exception
1064
- IncrementalAlterConfigsHandle.remove(handle.to_ptr.address)
1120
+ IncrementalAlterConfigsHandle.remove(handle.to_ptr.address) if registered
1065
1121
 
1066
1122
  raise
1067
1123
  ensure
1068
- Rdkafka::Bindings.rd_kafka_AdminOptions_destroy(admin_options_ptr)
1069
- Rdkafka::Bindings.rd_kafka_queue_destroy(queue_ptr)
1124
+ if admin_options_ptr && !admin_options_ptr.null?
1125
+ Rdkafka::Bindings.rd_kafka_AdminOptions_destroy(admin_options_ptr)
1126
+ end
1070
1127
 
1071
- if configs_array_ptr
1072
- Rdkafka::Bindings.rd_kafka_ConfigResource_destroy_array(
1073
- configs_array_ptr,
1074
- pointer_array.size
1075
- )
1128
+ Rdkafka::Bindings.rd_kafka_queue_destroy(queue_ptr) if queue_ptr && !queue_ptr.null?
1129
+
1130
+ pointer_array.each do |config_resource_ptr|
1131
+ next if config_resource_ptr.null?
1132
+
1133
+ Rdkafka::Bindings.rd_kafka_ConfigResource_destroy(config_resource_ptr)
1076
1134
  end
1077
1135
  end
1078
1136
 
@@ -161,6 +161,7 @@ module Rdkafka
161
161
 
162
162
  attach_function :rd_kafka_DescribeConfigs, [:pointer, :pointer, :size_t, :pointer, :pointer], :void, blocking: true
163
163
  attach_function :rd_kafka_ConfigResource_new, [:int32, :pointer], :pointer
164
+ attach_function :rd_kafka_ConfigResource_destroy, [:pointer], :void
164
165
  attach_function :rd_kafka_ConfigResource_destroy_array, [:pointer, :int32], :void
165
166
  attach_function :rd_kafka_event_DescribeConfigs_result, [:pointer], :pointer
166
167
  attach_function :rd_kafka_DescribeConfigs_result_resources, [:pointer, :pointer], :pointer
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rdkafka
4
+ # Tracks live public clients so they can be closed before Ruby begins forced finalization.
5
+ # @private
6
+ module Clients
7
+ # Ruby rewrote `ObjectSpace::WeakMap` in 3.3. On 3.2 and earlier its `[]` can hand back a
8
+ # foreign or already-freed object for a key, which raises `NoMethodError` or segfaults the VM
9
+ # the moment the value is touched. Track weakly only where that is safe and fall back to a
10
+ # strong registry - pruned as clients close - on older Rubies. The strong registry holds an
11
+ # open, un-closed client until shutdown, which is exactly when this cleanup needs it anyway.
12
+ WEAK_TRACKING = Gem::Version.new(RUBY_VERSION) >= Gem::Version.new("3.3")
13
+
14
+ @pid = Process.pid
15
+ @mutex = Mutex.new
16
+
17
+ if WEAK_TRACKING
18
+ @clients = ObjectSpace::WeakMap.new
19
+ @tokens = [].freeze
20
+ else
21
+ @clients = {}.freeze
22
+ end
23
+
24
+ # Registers a successfully constructed client so it can be closed during process shutdown.
25
+ # @param client [Consumer, Producer, Admin] client to close during process shutdown
26
+ # @return [Consumer, Producer, Admin] the registered client
27
+ def self.register(client)
28
+ reset_after_fork
29
+
30
+ @mutex.synchronize do
31
+ if WEAK_TRACKING
32
+ live_clients
33
+
34
+ # The strongly-held token is the key and the client is the weak value, so registering a
35
+ # client never keeps it alive. Enumerating weak keys can expose reclaimed slots, so we
36
+ # look the value up by token instead.
37
+ token = Object.new
38
+ @clients[token] = client
39
+ @tokens = (@tokens + [token]).freeze
40
+ else
41
+ # Strong references, so drop clients that have already been closed to bound growth.
42
+ live = @clients.reject { |_id, existing| existing.closed? }
43
+ live[client.object_id] = client
44
+ @clients = live.freeze
45
+ end
46
+ end
47
+
48
+ client
49
+ end
50
+
51
+ # Closes each live client registered in this process.
52
+ # @return [nil]
53
+ # @raise [StandardError] the first close error, after attempting every client
54
+ def self.close_all
55
+ return unless @pid == Process.pid
56
+
57
+ clients = @mutex.synchronize { current_clients }
58
+ first_error = nil
59
+
60
+ clients.each do |client|
61
+ client.close unless client.closed?
62
+ rescue => error
63
+ first_error ||= error
64
+ end
65
+
66
+ raise first_error if first_error
67
+ end
68
+
69
+ # Returns the currently registered live clients.
70
+ # @return [Array<Consumer, Producer, Admin>] live clients
71
+ # @private
72
+ def self.current_clients
73
+ WEAK_TRACKING ? live_clients : @clients.values
74
+ end
75
+ private_class_method :current_clients
76
+
77
+ # Returns the live clients and releases tokens whose clients have been collected.
78
+ # Weak-tracking Rubies only.
79
+ # @return [Array<Consumer, Producer, Admin>] live clients
80
+ # @private
81
+ def self.live_clients
82
+ clients = []
83
+ tokens = []
84
+
85
+ @tokens.each do |token|
86
+ client = @clients[token]
87
+ clients << client if client
88
+ tokens << token if client
89
+ end
90
+
91
+ @tokens = tokens.freeze
92
+ clients
93
+ end
94
+ private_class_method :live_clients
95
+
96
+ # Replaces inherited state before registering a client in a forked child.
97
+ # @return [nil]
98
+ # @private
99
+ def self.reset_after_fork
100
+ return if @pid == Process.pid
101
+
102
+ if WEAK_TRACKING
103
+ @clients = ObjectSpace::WeakMap.new
104
+ @tokens = [].freeze
105
+ else
106
+ @clients = {}.freeze
107
+ end
108
+
109
+ @pid = Process.pid
110
+ @mutex = Mutex.new
111
+ end
112
+ private_class_method :reset_after_fork
113
+ end
114
+ end
@@ -209,21 +209,16 @@ module Rdkafka
209
209
  Rdkafka::Bindings.rd_kafka_conf_set_rebalance_cb(config, Rdkafka::Bindings::RebalanceCallback)
210
210
  end
211
211
 
212
- # Create native client
213
- kafka = native_kafka(config, :rd_kafka_consumer)
214
-
215
- # Redirect the main queue to the consumer queue
216
- Rdkafka::Bindings.rd_kafka_poll_set_consumer(kafka) if @consumer_poll_set
217
-
218
- # Return consumer with Kafka client
219
- Rdkafka::Consumer.new(
220
- Rdkafka::NativeKafka.new(
221
- kafka,
222
- run_polling_thread: false,
223
- opaque: opaque,
224
- auto_start: native_kafka_auto_start
225
- )
226
- )
212
+ build_native_client(
213
+ config,
214
+ :rd_kafka_consumer,
215
+ opaque: opaque,
216
+ run_polling_thread: false,
217
+ auto_start: native_kafka_auto_start
218
+ ) do |kafka, native_kafka|
219
+ Rdkafka::Bindings.rd_kafka_poll_set_consumer(kafka) if @consumer_poll_set
220
+ Rdkafka::Consumer.new(native_kafka)
221
+ end
227
222
  end
228
223
 
229
224
  # Create a producer with this configuration.
@@ -247,19 +242,17 @@ module Rdkafka
247
242
  # Return producer with Kafka client
248
243
  partitioner_name = self[:partitioner] || self["partitioner"]
249
244
 
250
- kafka = native_kafka(config, :rd_kafka_producer)
251
-
252
- Rdkafka::Producer.new(
253
- Rdkafka::NativeKafka.new(
254
- kafka,
255
- run_polling_thread: run_polling_thread,
256
- opaque: opaque,
257
- auto_start: native_kafka_auto_start,
258
- timeout_ms: native_kafka_poll_timeout_ms
259
- ),
260
- partitioner_name
261
- ).tap do |producer|
262
- opaque.producer = producer
245
+ build_native_client(
246
+ config,
247
+ :rd_kafka_producer,
248
+ opaque: opaque,
249
+ run_polling_thread: run_polling_thread,
250
+ auto_start: native_kafka_auto_start,
251
+ timeout_ms: native_kafka_poll_timeout_ms
252
+ ) do |_kafka, native_kafka|
253
+ Rdkafka::Producer.new(native_kafka, partitioner_name).tap do |producer|
254
+ opaque.producer = producer
255
+ end
263
256
  end
264
257
  end
265
258
 
@@ -279,17 +272,16 @@ module Rdkafka
279
272
  config = native_config(opaque)
280
273
  Rdkafka::Bindings.rd_kafka_conf_set_background_event_cb(config, Rdkafka::Callbacks::BackgroundEventCallbackFunction)
281
274
 
282
- kafka = native_kafka(config, :rd_kafka_producer)
283
-
284
- Rdkafka::Admin.new(
285
- Rdkafka::NativeKafka.new(
286
- kafka,
287
- run_polling_thread: run_polling_thread,
288
- opaque: opaque,
289
- auto_start: native_kafka_auto_start,
290
- timeout_ms: native_kafka_poll_timeout_ms
291
- )
292
- )
275
+ build_native_client(
276
+ config,
277
+ :rd_kafka_producer,
278
+ opaque: opaque,
279
+ run_polling_thread: run_polling_thread,
280
+ auto_start: native_kafka_auto_start,
281
+ timeout_ms: native_kafka_poll_timeout_ms
282
+ ) do |_kafka, native_kafka|
283
+ Rdkafka::Admin.new(native_kafka)
284
+ end
293
285
  end
294
286
 
295
287
  # Returns all configuration properties and their current values for this config.
@@ -343,6 +335,56 @@ module Rdkafka
343
335
 
344
336
  private
345
337
 
338
+ # Builds a public client around a native Kafka handle, destroying the current owner if setup
339
+ # fails before ownership is transferred to the caller.
340
+ # @param config [FFI::Pointer] native rdkafka configuration
341
+ # @param type [Symbol] native client type
342
+ # @param opaque [Object] callback context retained for the native client
343
+ # @param run_polling_thread [Boolean] whether to run a Ruby polling thread
344
+ # @param auto_start [Boolean] whether to start the polling thread after wrapping the client
345
+ # @param timeout_ms [Integer] polling timeout in milliseconds
346
+ # @yieldparam kafka [FFI::Pointer] native Kafka handle
347
+ # @yieldparam native_kafka [NativeKafka] Ruby owner of the native handle
348
+ # @return [Consumer, Producer, Admin] the constructed public client
349
+ # @private
350
+ def build_native_client(
351
+ config,
352
+ type,
353
+ opaque:,
354
+ run_polling_thread:,
355
+ auto_start:,
356
+ timeout_ms: Defaults::NATIVE_KAFKA_POLL_TIMEOUT_MS
357
+ )
358
+ kafka = native_kafka(config, type)
359
+ native_kafka = nil
360
+ client = nil
361
+
362
+ begin
363
+ native_kafka = Rdkafka::NativeKafka.new(
364
+ kafka,
365
+ run_polling_thread: run_polling_thread,
366
+ opaque: opaque,
367
+ auto_start: false,
368
+ timeout_ms: timeout_ms
369
+ )
370
+ client = yield(kafka, native_kafka)
371
+ native_kafka.start if auto_start
372
+ Rdkafka::Clients.register(client)
373
+ rescue Exception
374
+ owner = client || native_kafka
375
+
376
+ begin
377
+ owner ? owner.close : Rdkafka::Bindings.rd_kafka_destroy(kafka)
378
+ rescue Exception => err
379
+ # Never let a teardown failure replace the error that caused it: that error is what the
380
+ # caller needs to see, and it is re-raised below regardless.
381
+ Rdkafka::Config.logger.error("Unhandled exception: #{err.class} - #{err.message}")
382
+ end
383
+
384
+ raise
385
+ end
386
+ end
387
+
346
388
  # This method is only intended to be used to create a client,
347
389
  # using it in another way will leak memory.
348
390
  #
@@ -429,6 +471,9 @@ module Rdkafka
429
471
 
430
472
  # Return handle which should be closed using rd_kafka_destroy after usage.
431
473
  handle
474
+ rescue Exception
475
+ Rdkafka::Bindings.rd_kafka_destroy(handle) if handle && !handle.null?
476
+ raise
432
477
  end
433
478
  end
434
479
 
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rdkafka
4
+ class Consumer
5
+ # Creates FFI pointers from the process-global allocator so that memory handed to librdkafka
6
+ # can be released by it with the matching `free`.
7
+ module Allocator
8
+ extend FFI::Library
9
+
10
+ ffi_lib FFI::CURRENT_PROCESS # use process-global malloc not FFI::Library::LIBC
11
+ attach_function :malloc, [:size_t], :pointer
12
+
13
+ class << self
14
+ # Return an FFI pointer to a string allocated from the global allocator
15
+ # so that it can be released by another library.
16
+ #
17
+ # @private
18
+ #
19
+ # @param string [String] Ruby string that will be copied into the new
20
+ # string pointer.
21
+ #
22
+ # @return [FFI::Pointer]
23
+ def string_pointer(string)
24
+ bytes = string.to_s
25
+ pointer = malloc(bytes.bytesize + 1)
26
+ Kernel.raise(NoMemoryError, "malloc failed") if pointer.null?
27
+
28
+ pointer.put_bytes(0, bytes)
29
+ pointer.put_char(bytes.bytesize, 0) # NUL terminator
30
+
31
+ pointer.autorelease = false
32
+
33
+ pointer
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -160,10 +160,9 @@ module Rdkafka
160
160
 
161
161
  if p.metadata
162
162
  part = Rdkafka::Bindings::TopicPartition.new(ref)
163
- str_ptr = FFI::MemoryPointer.from_string(p.metadata)
164
163
  # The metadata string is owned by librdkafka once handed over and released here:
165
164
  # https://github.com/confluentinc/librdkafka/blob/e03d3bb91ed92a38f38d9806b8d8deffe78a1de5/src/rdkafka_partition.c#L2682C18-L2682C18
166
- str_ptr.autorelease = false
165
+ str_ptr = Allocator.string_pointer(p.metadata)
167
166
  part[:metadata] = str_ptr
168
167
  part[:metadata_size] = p.metadata.bytesize
169
168
  end
@@ -916,33 +916,35 @@ module Rdkafka
916
916
  begin
917
917
  while i < count
918
918
  ptr = buffer.get_pointer(i * FFI::Pointer.size)
919
+ # Take ownership of this index before processing the pointer: advancing `i` up front means
920
+ # the cleanup loop in the `ensure` below starts past it, so a raise mid-iteration cannot
921
+ # double-free the same message.
922
+ i += 1
919
923
 
920
- if ptr.null?
921
- i += 1
922
- next
923
- end
924
+ next if ptr.null?
924
925
 
925
- native_message = Rdkafka::Bindings::Message.new(ptr)
926
+ # One `ensure` around the whole body guarantees `ptr` is destroyed exactly once on every
927
+ # path - the error branch, a successful build, a rescued `RdkafkaError`, or any other
928
+ # exception unwinding through here - so nothing leaks in the window before the build.
929
+ begin
930
+ native_message = Rdkafka::Bindings::Message.new(ptr)
926
931
 
927
- if native_message[:err] != Rdkafka::Bindings::RD_KAFKA_RESP_ERR_NO_ERROR
928
- results << Rdkafka::RdkafkaError.new(native_message[:err])
929
- Rdkafka::Bindings.rd_kafka_message_destroy(ptr)
930
- i += 1
931
- next
932
- end
932
+ if native_message[:err] != Rdkafka::Bindings::RD_KAFKA_RESP_ERR_NO_ERROR
933
+ results << Rdkafka::RdkafkaError.new(native_message[:err])
934
+ next
935
+ end
933
936
 
934
- begin
935
- results << Rdkafka::Consumer::Message.new(native_message)
936
- rescue Rdkafka::RdkafkaError => e
937
- # A message that fails to build (e.g. a header read error) is surfaced inline as an
938
- # error event rather than discarding the whole batch - including the messages already
939
- # built - and raising, which silently lost them once their offsets had been stored.
940
- results << e
937
+ begin
938
+ results << Rdkafka::Consumer::Message.new(native_message)
939
+ rescue Rdkafka::RdkafkaError => e
940
+ # A message that fails to build (e.g. a header read error) is surfaced inline as an
941
+ # error event rather than discarding the whole batch - including the messages already
942
+ # built - and raising, which silently lost them once their offsets had been stored.
943
+ results << e
944
+ end
941
945
  ensure
942
946
  Rdkafka::Bindings.rd_kafka_message_destroy(ptr)
943
947
  end
944
-
945
- i += 1
946
948
  end
947
949
  ensure
948
950
  while i < count
@@ -991,33 +993,35 @@ module Rdkafka
991
993
  begin
992
994
  while i < count
993
995
  ptr = buffer.get_pointer(i * FFI::Pointer.size)
996
+ # Take ownership of this index before processing the pointer: advancing `i` up front means
997
+ # the cleanup loop in the `ensure` below starts past it, so a raise mid-iteration cannot
998
+ # double-free the same message.
999
+ i += 1
994
1000
 
995
- if ptr.null?
996
- i += 1
997
- next
998
- end
1001
+ next if ptr.null?
999
1002
 
1000
- native_message = Rdkafka::Bindings::Message.new(ptr)
1003
+ # One `ensure` around the whole body guarantees `ptr` is destroyed exactly once on every
1004
+ # path - the error branch, a successful build, a rescued `RdkafkaError`, or any other
1005
+ # exception unwinding through here - so nothing leaks in the window before the build.
1006
+ begin
1007
+ native_message = Rdkafka::Bindings::Message.new(ptr)
1001
1008
 
1002
- if native_message[:err] != Rdkafka::Bindings::RD_KAFKA_RESP_ERR_NO_ERROR
1003
- results << Rdkafka::RdkafkaError.new(native_message[:err])
1004
- Rdkafka::Bindings.rd_kafka_message_destroy(ptr)
1005
- i += 1
1006
- next
1007
- end
1009
+ if native_message[:err] != Rdkafka::Bindings::RD_KAFKA_RESP_ERR_NO_ERROR
1010
+ results << Rdkafka::RdkafkaError.new(native_message[:err])
1011
+ next
1012
+ end
1008
1013
 
1009
- begin
1010
- results << Rdkafka::Consumer::Message.new(native_message)
1011
- rescue Rdkafka::RdkafkaError => e
1012
- # A message that fails to build (e.g. a header read error) is surfaced inline as an
1013
- # error event rather than discarding the whole batch - including the messages already
1014
- # built - and raising, which silently lost them once their offsets had been stored.
1015
- results << e
1014
+ begin
1015
+ results << Rdkafka::Consumer::Message.new(native_message)
1016
+ rescue Rdkafka::RdkafkaError => e
1017
+ # A message that fails to build (e.g. a header read error) is surfaced inline as an
1018
+ # error event rather than discarding the whole batch - including the messages already
1019
+ # built - and raising, which silently lost them once their offsets had been stored.
1020
+ results << e
1021
+ end
1016
1022
  ensure
1017
1023
  Rdkafka::Bindings.rd_kafka_message_destroy(ptr)
1018
1024
  end
1019
-
1020
- i += 1
1021
1025
  end
1022
1026
  ensure
1023
1027
  while i < count
@@ -2,7 +2,7 @@
2
2
 
3
3
  module Rdkafka
4
4
  # Current rdkafka-ruby gem version
5
- VERSION = "0.29.1"
5
+ VERSION = "0.29.2"
6
6
  # Target librdkafka version to be used
7
7
  LIBRDKAFKA_VERSION = "2.14.2"
8
8
  # SHA256 hash of the librdkafka source tarball for verification
data/lib/rdkafka.rb CHANGED
@@ -60,6 +60,7 @@ require "rdkafka/consumer"
60
60
  require "rdkafka/consumer/headers"
61
61
  require "rdkafka/consumer/message"
62
62
  require "rdkafka/consumer/partition"
63
+ require "rdkafka/consumer/allocator"
63
64
  require "rdkafka/consumer/topic_partition_list"
64
65
  require "rdkafka/error"
65
66
  require "rdkafka/metadata"
@@ -68,9 +69,12 @@ require "rdkafka/producer/partitions_count_cache"
68
69
  require "rdkafka/producer"
69
70
  require "rdkafka/producer/delivery_handle"
70
71
  require "rdkafka/producer/delivery_report"
72
+ require "rdkafka/clients"
71
73
 
72
74
  # Main Rdkafka namespace of this gem
73
75
  module Rdkafka
74
76
  end
75
77
 
76
78
  Rdkafka::Bindings.rd_kafka_global_init
79
+
80
+ at_exit { Rdkafka::Clients.close_all }
data/package-lock.json CHANGED
@@ -103,9 +103,9 @@
103
103
  }
104
104
  },
105
105
  "node_modules/fastq": {
106
- "version": "1.20.1",
107
- "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
108
- "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==",
106
+ "version": "1.20.2",
107
+ "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.2.tgz",
108
+ "integrity": "sha512-UpGiiODyCGprM8EPP6JodP6jC9Rws6TCuiDOD+nn0CJhR8guI3g/ozo4ugL0vJ+Yz1UtJuuRPqvQuybVOF1VQA==",
109
109
  "dev": true,
110
110
  "license": "ISC",
111
111
  "dependencies": {
@@ -286,9 +286,9 @@
286
286
  }
287
287
  },
288
288
  "node_modules/smol-toml": {
289
- "version": "1.7.1",
290
- "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.7.1.tgz",
291
- "integrity": "sha512-PPlsspAZ4jbMBu5DMFhfUGDQLu/vrL4SyBROVS37x8ynnVmFIs1VPBz1Co8Xks3TvpIaZXmU85y4DrQ+UyVFoQ==",
289
+ "version": "1.8.0",
290
+ "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.8.0.tgz",
291
+ "integrity": "sha512-kCZr2V3ch9i00x8zXRhjUNVcjG9ijES5dDudkXvUVCT5QlJNQWElSJdZqyPemffHoLNUYwOcou0Fy+ojN0uHSQ==",
292
292
  "dev": true,
293
293
  "license": "BSD-3-Clause",
294
294
  "engines": {
data/renovate.json CHANGED
@@ -27,7 +27,7 @@
27
27
  ],
28
28
  "depNameTemplate": "openssl/openssl",
29
29
  "datasourceTemplate": "github-releases",
30
- "extractVersionTemplate": "^OpenSSL_(?<version>.*)$"
30
+ "extractVersionTemplate": "^openssl-(?<version>.*)$"
31
31
  },
32
32
  {
33
33
  "customType": "regex",
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rdkafka
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.29.1
4
+ version: 0.29.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Thijs Cadier
@@ -143,8 +143,10 @@ files:
143
143
  - lib/rdkafka/callbacks/incremental_alter_configs_handler.rb
144
144
  - lib/rdkafka/callbacks/list_consumer_groups_handler.rb
145
145
  - lib/rdkafka/callbacks/list_offsets_handler.rb
146
+ - lib/rdkafka/clients.rb
146
147
  - lib/rdkafka/config.rb
147
148
  - lib/rdkafka/consumer.rb
149
+ - lib/rdkafka/consumer/allocator.rb
148
150
  - lib/rdkafka/consumer/headers.rb
149
151
  - lib/rdkafka/consumer/message.rb
150
152
  - lib/rdkafka/consumer/partition.rb