karafka-rdkafka 0.28.1-aarch64-linux-gnu → 0.28.2-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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: cb6dd6580bf5c14ddceaa2ccd38deaad34cf4e6e81e994c82df73e62fb831f77
4
- data.tar.gz: c26f74ea68b3c14ace182f7f8b2da1184cceb59d6063b044a7efb3a84a022eef
3
+ metadata.gz: c55da5cc4ca09c9f37305039ff4e042da143270bbd2ca7be3e32821c2bab06c9
4
+ data.tar.gz: 06b1da56a0467dfecd246c9b32916875eac10f1adb050332a58da7063f02d169
5
5
  SHA512:
6
- metadata.gz: 6b954711968c6acaa5beffa2995a33262bef8e2297b18eb3d9e828dd0be43818bfa5be73a7a20bfe13a8d19ee08ec4436e38e7b63a69948f3b85b7d06843d5db
7
- data.tar.gz: f2003f4f608143b78dd9caab59a30d2fd96b5ed1a280ea9d1388cb4d5ff386d26655f2b0b3e15ec16b450b7df23118cfae1f1f99f229139e19243cd6bb7f3d5c
6
+ metadata.gz: 68dada1abf386a99173495683243015e6910c1e9fdb06009f6496251aeefd7007395c199d50f48c17968ce982d03b8acc41dff5779fa14a28b822a6dabb0daab
7
+ data.tar.gz: 406ba779b17fb15d8384979ac9f58d666e020848ae973366905e37794c6e3bec3b2935e589ded09cf4fee40d3d1eb135fe9e0e82e9620311d87335c050db5760
data/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  # Rdkafka Changelog
2
2
 
3
+ ## 0.28.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). Ported from rdkafka-ruby (#964, Alex Selesse).
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. Ported from rdkafka-ruby (#964, Alex Selesse).
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. Ported from rdkafka-ruby (#973).
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). Ported from rdkafka-ruby (#973).
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. Ported from rdkafka-ruby (#973).
9
+ - [Fix] Allocate the topic-partition metadata string with a process-global malloc instead of an autorelease-off `FFI::MemoryPointer`, so the buffer handed to librdkafka is not freed under it. Ported from rdkafka-ruby (#969, Randy Stauner).
10
+ - [Maintenance] Bump the bundled OpenSSL used by the precompiled builds to `3.5.8` (LTS). Ported from rdkafka-ruby (#972, Scott Francis).
11
+
3
12
  ## v0.28.1 (2026-09-04)
4
13
  - [Enhancement] Add `Admin#delete_records`, wrapping librdkafka's `DeleteRecords` admin API. Deletes all messages in a partition up to (but not including) a given offset - accepts an integer offset or `:end` to clear all current data. Ported from rdkafka-ruby (#956).
5
14
  - [Enhancement] Add `Admin#list_consumer_groups`, wrapping librdkafka's `ListConsumerGroups` admin API. Returns a cluster-wide listing of consumer groups (`group_id`, `is_simple_consumer_group`, state) plus any per-broker errors. Ported from rdkafka-ruby (#955).
data/ext/librdkafka.so CHANGED
Binary file
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,61 +1018,86 @@ 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 (build_from_c also
1022
- # destroys the 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
- error = Rdkafka::RdkafkaError.build_from_c(
1031
- error_ptr,
1032
- "rd_kafka_ConfigResource_add_incremental_config"
1033
- )
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
1034
1072
 
1035
- add_error ||= error if error
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 (build_from_c also
1081
+ # destroys the 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
+ error = Rdkafka::RdkafkaError.build_from_c(
1090
+ error_ptr,
1091
+ "rd_kafka_ConfigResource_add_incremental_config"
1092
+ )
1093
+
1094
+ add_error ||= error if error
1095
+ end
1036
1096
  end
1037
1097
 
1038
- resource_ptr
1039
- end
1040
-
1041
- configs_array_ptr = FFI::MemoryPointer.new(:pointer, pointer_array.size)
1042
- configs_array_ptr.write_array_of_pointer(pointer_array)
1098
+ configs_array_ptr = FFI::MemoryPointer.new(:pointer, pointer_array.size)
1099
+ configs_array_ptr.write_array_of_pointer(pointer_array)
1043
1100
 
1044
- begin
1045
1101
  # Raise only after the full array is built so the ensure below frees every ConfigResource
1046
1102
  # we created, and before the request is sent so a rejected entry can't report success.
1047
1103
  raise add_error if add_error
@@ -1056,18 +1112,20 @@ module Rdkafka
1056
1112
  )
1057
1113
  end
1058
1114
  rescue Exception
1059
- IncrementalAlterConfigsHandle.remove(handle.to_ptr.address)
1115
+ IncrementalAlterConfigsHandle.remove(handle.to_ptr.address) if registered
1060
1116
 
1061
1117
  raise
1062
1118
  ensure
1063
- Rdkafka::Bindings.rd_kafka_AdminOptions_destroy(admin_options_ptr)
1064
- Rdkafka::Bindings.rd_kafka_queue_destroy(queue_ptr)
1119
+ if admin_options_ptr && !admin_options_ptr.null?
1120
+ Rdkafka::Bindings.rd_kafka_AdminOptions_destroy(admin_options_ptr)
1121
+ end
1065
1122
 
1066
- if configs_array_ptr
1067
- Rdkafka::Bindings.rd_kafka_ConfigResource_destroy_array(
1068
- configs_array_ptr,
1069
- pointer_array.size
1070
- )
1123
+ Rdkafka::Bindings.rd_kafka_queue_destroy(queue_ptr) if queue_ptr && !queue_ptr.null?
1124
+
1125
+ pointer_array.each do |config_resource_ptr|
1126
+ next if config_resource_ptr.null?
1127
+
1128
+ Rdkafka::Bindings.rd_kafka_ConfigResource_destroy(config_resource_ptr)
1071
1129
  end
1072
1130
  end
1073
1131
 
@@ -170,6 +170,7 @@ module Rdkafka
170
170
 
171
171
  attach_function :rd_kafka_DescribeConfigs, [:pointer, :pointer, :size_t, :pointer, :pointer], :void, blocking: true
172
172
  attach_function :rd_kafka_ConfigResource_new, [:int32, :pointer], :pointer
173
+ attach_function :rd_kafka_ConfigResource_destroy, [:pointer], :void
173
174
  attach_function :rd_kafka_ConfigResource_destroy_array, [:pointer, :int32], :void
174
175
  attach_function :rd_kafka_event_DescribeConfigs_result, [:pointer], :pointer
175
176
  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
@@ -162,10 +162,9 @@ module Rdkafka
162
162
  # Remove the respond to check after karafka 2.3.0 is released
163
163
  if p.respond_to?(:metadata) && p.metadata
164
164
  part = Rdkafka::Bindings::TopicPartition.new(ref)
165
- str_ptr = FFI::MemoryPointer.from_string(p.metadata)
166
- # released here:
165
+ # The metadata string is owned by librdkafka once handed over and released here:
167
166
  # https://github.com/confluentinc/librdkafka/blob/e03d3bb91ed92a38f38d9806b8d8deffe78a1de5/src/rdkafka_partition.c#L2682C18-L2682C18
168
- str_ptr.autorelease = false
167
+ str_ptr = Allocator.string_pointer(p.metadata)
169
168
  part[:metadata] = str_ptr
170
169
  part[:metadata_size] = p.metadata.bytesize
171
170
  end
@@ -906,33 +906,35 @@ module Rdkafka
906
906
  begin
907
907
  while i < count
908
908
  ptr = buffer.get_pointer(i * FFI::Pointer.size)
909
+ # Take ownership of this index before processing the pointer: advancing `i` up front means
910
+ # the cleanup loop in the `ensure` below starts past it, so a raise mid-iteration cannot
911
+ # double-free the same message.
912
+ i += 1
909
913
 
910
- if ptr.null?
911
- i += 1
912
- next
913
- end
914
+ next if ptr.null?
914
915
 
915
- native_message = Rdkafka::Bindings::Message.new(ptr)
916
+ # One `ensure` around the whole body guarantees `ptr` is destroyed exactly once on every
917
+ # path - the error branch, a successful build, a rescued `RdkafkaError`, or any other
918
+ # exception unwinding through here - so nothing leaks in the window before the build.
919
+ begin
920
+ native_message = Rdkafka::Bindings::Message.new(ptr)
916
921
 
917
- if native_message[:err] != Rdkafka::Bindings::RD_KAFKA_RESP_ERR_NO_ERROR
918
- results << build_batch_error(native_message)
919
- Rdkafka::Bindings.rd_kafka_message_destroy(ptr)
920
- i += 1
921
- next
922
- end
922
+ if native_message[:err] != Rdkafka::Bindings::RD_KAFKA_RESP_ERR_NO_ERROR
923
+ results << build_batch_error(native_message)
924
+ next
925
+ end
923
926
 
924
- begin
925
- results << Rdkafka::Consumer::Message.new(native_message)
926
- rescue Rdkafka::RdkafkaError => e
927
- # A message that fails to build (e.g. a header read error) is surfaced inline as an
928
- # error event rather than discarding the whole batch - including the messages already
929
- # built - and raising, which silently lost them once their offsets had been stored.
930
- results << e
927
+ begin
928
+ results << Rdkafka::Consumer::Message.new(native_message)
929
+ rescue Rdkafka::RdkafkaError => e
930
+ # A message that fails to build (e.g. a header read error) is surfaced inline as an
931
+ # error event rather than discarding the whole batch - including the messages already
932
+ # built - and raising, which silently lost them once their offsets had been stored.
933
+ results << e
934
+ end
931
935
  ensure
932
936
  Rdkafka::Bindings.rd_kafka_message_destroy(ptr)
933
937
  end
934
-
935
- i += 1
936
938
  end
937
939
  ensure
938
940
  while i < count
@@ -981,33 +983,35 @@ module Rdkafka
981
983
  begin
982
984
  while i < count
983
985
  ptr = buffer.get_pointer(i * FFI::Pointer.size)
986
+ # Take ownership of this index before processing the pointer: advancing `i` up front means
987
+ # the cleanup loop in the `ensure` below starts past it, so a raise mid-iteration cannot
988
+ # double-free the same message.
989
+ i += 1
984
990
 
985
- if ptr.null?
986
- i += 1
987
- next
988
- end
991
+ next if ptr.null?
989
992
 
990
- native_message = Rdkafka::Bindings::Message.new(ptr)
993
+ # One `ensure` around the whole body guarantees `ptr` is destroyed exactly once on every
994
+ # path - the error branch, a successful build, a rescued `RdkafkaError`, or any other
995
+ # exception unwinding through here - so nothing leaks in the window before the build.
996
+ begin
997
+ native_message = Rdkafka::Bindings::Message.new(ptr)
991
998
 
992
- if native_message[:err] != Rdkafka::Bindings::RD_KAFKA_RESP_ERR_NO_ERROR
993
- results << build_batch_error(native_message)
994
- Rdkafka::Bindings.rd_kafka_message_destroy(ptr)
995
- i += 1
996
- next
997
- end
999
+ if native_message[:err] != Rdkafka::Bindings::RD_KAFKA_RESP_ERR_NO_ERROR
1000
+ results << build_batch_error(native_message)
1001
+ next
1002
+ end
998
1003
 
999
- begin
1000
- results << Rdkafka::Consumer::Message.new(native_message)
1001
- rescue Rdkafka::RdkafkaError => e
1002
- # A message that fails to build (e.g. a header read error) is surfaced inline as an
1003
- # error event rather than discarding the whole batch - including the messages already
1004
- # built - and raising, which silently lost them once their offsets had been stored.
1005
- results << e
1004
+ begin
1005
+ results << Rdkafka::Consumer::Message.new(native_message)
1006
+ rescue Rdkafka::RdkafkaError => e
1007
+ # A message that fails to build (e.g. a header read error) is surfaced inline as an
1008
+ # error event rather than discarding the whole batch - including the messages already
1009
+ # built - and raising, which silently lost them once their offsets had been stored.
1010
+ results << e
1011
+ end
1006
1012
  ensure
1007
1013
  Rdkafka::Bindings.rd_kafka_message_destroy(ptr)
1008
1014
  end
1009
-
1010
- i += 1
1011
1015
  end
1012
1016
  ensure
1013
1017
  while i < count
@@ -2,7 +2,7 @@
2
2
 
3
3
  module Rdkafka
4
4
  # Current rdkafka-ruby gem version
5
- VERSION = "0.28.1"
5
+ VERSION = "0.28.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.0",
290
- "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.7.0.tgz",
291
- "integrity": "sha512-aqVvWoyO21L23mb+drl4RmMXbf6N7FdHjAhTRA9ZBL7apWBgfWC16KjrASI+1p9GAroljyMHj6fK67i0UiTNvQ==",
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: karafka-rdkafka
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.28.1
4
+ version: 0.28.2
5
5
  platform: aarch64-linux-gnu
6
6
  authors:
7
7
  - Thijs Cadier
@@ -141,8 +141,10 @@ files:
141
141
  - lib/rdkafka/callbacks/incremental_alter_configs_handler.rb
142
142
  - lib/rdkafka/callbacks/list_consumer_groups_handler.rb
143
143
  - lib/rdkafka/callbacks/list_offsets_handler.rb
144
+ - lib/rdkafka/clients.rb
144
145
  - lib/rdkafka/config.rb
145
146
  - lib/rdkafka/consumer.rb
147
+ - lib/rdkafka/consumer/allocator.rb
146
148
  - lib/rdkafka/consumer/headers.rb
147
149
  - lib/rdkafka/consumer/message.rb
148
150
  - lib/rdkafka/consumer/partition.rb