karafka-rdkafka 0.29.0-aarch64-linux-gnu → 0.30.1-aarch64-linux-gnu

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rdkafka
4
+ class ShareConsumer
5
+ # A message that was consumed from a topic through a share group.
6
+ #
7
+ # On top of the regular consumer message data it carries the delivery count maintained by
8
+ # the broker and, for record-level errors delivered inside a batch, the error itself.
9
+ class Message < Rdkafka::Consumer::Message
10
+ # Number of times the broker has delivered this record (1 for the first delivery). A
11
+ # record released explicitly or via an expired acquisition lock is redelivered with an
12
+ # incremented delivery count until the broker's delivery count limit archives it.
13
+ # @return [Integer]
14
+ attr_reader :delivery_count
15
+
16
+ # Record-level error for this batch entry, if any. Topic, partition and offset are valid
17
+ # on error entries while the payload is not a consumed record. Error entries do not need
18
+ # to be acknowledged.
19
+ # @return [RdkafkaError, nil]
20
+ attr_reader :error
21
+
22
+ # @private
23
+ # @param native_message [Rdkafka::Bindings::Message] native message struct from librdkafka
24
+ # @param delivery_count [Integer] broker-maintained delivery count
25
+ # @param error [RdkafkaError, nil] record-level error
26
+ def initialize(native_message, delivery_count:, error: nil)
27
+ super(native_message)
28
+
29
+ @delivery_count = delivery_count
30
+ @error = error
31
+ end
32
+
33
+ # Whether this batch entry is a record-level error rather than a consumed record
34
+ # @return [Boolean]
35
+ def error?
36
+ !@error.nil?
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,573 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rdkafka
4
+ # A KIP-932 share group consumer of Kafka messages.
5
+ #
6
+ # Share groups bring queue-like semantics to Kafka: multiple members of the same group
7
+ # consume from the same partitions cooperatively and progress is tracked per record via
8
+ # acknowledgements (`:accept`, `:release`, `:reject`) instead of committed offsets. Partition
9
+ # assignment is entirely broker-driven: there is no rebalance callback and no `assign` step.
10
+ #
11
+ # To create a share consumer set up a {Config} with `:"group.id"` and call
12
+ # {Config#share_consumer share_consumer} on it. The acknowledgement mode is selected with the
13
+ # `:"share.acknowledgement.mode"` config property (`implicit`, the default, acknowledges every
14
+ # polled record as accepted on the next poll/commit; `explicit` requires every record to be
15
+ # acknowledged via {#acknowledge} before the next poll).
16
+ #
17
+ # @note The share consumer is a **preview** feature of librdkafka and requires a broker with
18
+ # share groups enabled (Apache Kafka 4.2.0+). Its API may change before general availability
19
+ # and it is not recommended for production use.
20
+ #
21
+ # @note The share consumer handle is **not thread-safe by design** (matching KIP-932): use one
22
+ # share consumer per thread. librdkafka enforces this and calls from a second thread raise
23
+ # an `RdkafkaError` with code `conflict`. This class is deliberately not built on
24
+ # {NativeKafka}: there is no background polling thread (statistics, error and log callbacks
25
+ # are all serviced from within {#poll}) and the native handle must never be used from a
26
+ # polling thread, so only the {#close} interaction is synchronized here. {#close} may be
27
+ # called from any thread and waits for in-flight calls to finish before tearing down.
28
+ #
29
+ # Preview limitations inherited from librdkafka worth knowing at this layer:
30
+ # - `max.poll.records` (default 500) is a soft bound
31
+ # - a blocking poll can only be ended by its timeout (no wakeup API)
32
+ # - {#close} takes no timeout and is bounded internally by `socket.timeout.ms`
33
+ # - failed acknowledgements are not retried automatically; outcomes are reported through
34
+ # {#acknowledgement_commit_callback=} or the per-partition results of {#commit_sync}
35
+ class ShareConsumer
36
+ include Enumerable
37
+ include Helpers::OAuth
38
+
39
+ # Mapping of friendly acknowledge types to their librdkafka enum values
40
+ ACKNOWLEDGE_TYPES = {
41
+ accept: Bindings::RD_KAFKA_SHARE_ACKNOWLEDGE_TYPE_ACCEPT,
42
+ release: Bindings::RD_KAFKA_SHARE_ACKNOWLEDGE_TYPE_RELEASE,
43
+ reject: Bindings::RD_KAFKA_SHARE_ACKNOWLEDGE_TYPE_REJECT
44
+ }.freeze
45
+
46
+ private_constant :ACKNOWLEDGE_TYPES
47
+
48
+ # State shared with the GC finalizer. Carrying the registered acknowledgement commit FFI
49
+ # function next to the native handle guarantees its native trampoline outlives the consumer
50
+ # object: the finalizer-driven destroy flushes pending acknowledgements through that
51
+ # callback, so it must not be collected in the same GC cycle as the consumer. `creator_pid`
52
+ # rides along so the finalizer (which must not capture `self`) can tell whether it is running
53
+ # in the process that created the handle - librdkafka is not fork-safe, so an inherited handle
54
+ # must never be destroyed from a forked child (its backing threads do not exist there).
55
+ State = Struct.new(:native, :ack_callback, :creator_pid)
56
+
57
+ private_constant :State
58
+
59
+ # The client name librdkafka reports for this consumer (e.g. "rdkafka#consumer-1"), used to
60
+ # correlate the global callbacks (statistics, error and the OAuthBearer token refresh
61
+ # callback, all of which carry the client name) with this instance. Captured from the native
62
+ # handle when the consumer is created, so it is available immediately - before the first
63
+ # {#poll} and without OAuth configured - and stays readable after the consumer is closed or
64
+ # inherited by a forked child.
65
+ # @return [String] the librdkafka client name
66
+ attr_reader :name
67
+
68
+ # @private
69
+ # Lets the OAuthBearer token refresh callback set the name from its callback context. It
70
+ # carries the same `rd_kafka_name` value already captured at creation, so both paths agree.
71
+ attr_writer :name
72
+
73
+ # @private
74
+ # @param native [FFI::Pointer] pointer to the native rd_kafka_share_t handle
75
+ # @param opaque [Rdkafka::Opaque, nil] opaque used for callback context. Held here so it is
76
+ # not garbage collected while the native client can still reference it
77
+ # (Config.opaques is a WeakMap).
78
+ def initialize(native, opaque: nil)
79
+ @opaque = opaque
80
+ # State shared with the GC finalizer so it can destroy the native handle (and keep the
81
+ # registered acknowledgement callback alive while doing so) without capturing `self`
82
+ # (which would pin the consumer and prevent collection). The creating pid rides along so
83
+ # every teardown path can skip the native destroy in a forked child (librdkafka is not
84
+ # fork-safe).
85
+ @state = State.new(native, nil, Process.pid)
86
+ # Capture the librdkafka client name up front. It is needed to correlate this consumer with
87
+ # the global statistics and error callbacks (which carry the client name) - the common
88
+ # downstream pattern (e.g. Karafka) filters those broadcast callbacks by matching `name`.
89
+ #
90
+ # `rd_kafka_name` has no `rd_kafka_share_t` overload: handing it the share handle reads an
91
+ # unrelated field and returns garbage. The share handle's first member is the `rd_kafka_t`
92
+ # it wraps (`rkshare_rk`), which is the same handle librdkafka passes to those callbacks, so
93
+ # the name read from it matches the statistics `name` field. Capturing it here (rather than
94
+ # lazily like the regular consumer) keeps it available before the first poll, without OAuth,
95
+ # and after close or in a forked child, where the native handle must not be touched.
96
+ # librdkafka assigns the name at creation, so no broker connection is required.
97
+ @name = Rdkafka::Bindings.rd_kafka_name(Rdkafka::Bindings::NativeShareConsumer.new(native)[:rkshare_rk])
98
+ # Guards handle teardown against in-flight calls: increments happen under this mutex and
99
+ # close holds it while draining and destroying (mirrors NativeKafka's approach)
100
+ @access_mutex = Mutex.new
101
+ # Separate mutex for decrements so a blocked close (holding @access_mutex) cannot
102
+ # deadlock with an operation trying to finish
103
+ @decrement_mutex = Mutex.new
104
+ # Number of calls currently using the native handle
105
+ @operations_in_progress = 0
106
+
107
+ ObjectSpace.define_finalizer(self, self.class.finalizer(@state))
108
+ end
109
+
110
+ # Builds the GC finalizer for a share consumer. `rd_kafka_share_destroy` internally closes
111
+ # the consumer (sending pending acknowledgements and leaving the group) when it was not
112
+ # closed explicitly. The acknowledgement callback function rides along in the state holder
113
+ # so it is still alive when that close flushes acknowledgements through it.
114
+ #
115
+ # @private
116
+ # @param state [State] holder carrying the native handle, the ack callback function and the
117
+ # creating pid
118
+ # @return [Proc] finalizer proc that must not reference the consumer instance
119
+ def self.finalizer(state)
120
+ proc do
121
+ native = state.native
122
+
123
+ # librdkafka is not fork-safe: a handle inherited by a forked child must never be
124
+ # destroyed there (its backing threads do not exist in the child), so only the process
125
+ # that created it runs the native teardown.
126
+ if native && state.creator_pid == Process.pid
127
+ state.native = nil
128
+ error = Rdkafka::Bindings.rd_kafka_share_destroy(native)
129
+ Rdkafka::Bindings.rd_kafka_error_destroy(error) unless error.null?
130
+ state.ack_callback = nil
131
+ end
132
+ end
133
+ end
134
+
135
+ # Subscribes to one or more topics letting the broker assign partitions to the members of
136
+ # the share group.
137
+ #
138
+ # Wildcard (regex) subscriptions are not supported by share consumers.
139
+ #
140
+ # @param topics [Array<String>] One or more topic names
141
+ # @return [nil]
142
+ # @raise [ArgumentError] When no topics are given. librdkafka would treat an empty
143
+ # subscribe as an unsubscribe (unlike the regular consumer, which rejects it), which
144
+ # silently drops the subscription - use {#unsubscribe} for that.
145
+ # @raise [RdkafkaError] When subscribing fails
146
+ def subscribe(*topics)
147
+ raise ArgumentError, "at least one topic is required (an empty subscribe would unsubscribe)" if topics.empty?
148
+
149
+ with_native(__method__) do |native|
150
+ tpl = Rdkafka::Bindings.rd_kafka_topic_partition_list_new(topics.length)
151
+
152
+ begin
153
+ topics.each do |topic|
154
+ Rdkafka::Bindings.rd_kafka_topic_partition_list_add(tpl, topic, Rdkafka::Bindings::RD_KAFKA_PARTITION_UA)
155
+ end
156
+
157
+ response = Rdkafka::Bindings.rd_kafka_share_subscribe(native, tpl)
158
+ Rdkafka::RdkafkaError.validate!(response, "Error subscribing to '#{topics.join(", ")}'")
159
+ ensure
160
+ Rdkafka::Bindings.rd_kafka_topic_partition_list_destroy(tpl)
161
+ end
162
+ end
163
+
164
+ nil
165
+ end
166
+
167
+ # Unsubscribes from all subscribed topics and leaves the share group assignment.
168
+ #
169
+ # @return [nil]
170
+ # @raise [RdkafkaError] When unsubscribing fails
171
+ def unsubscribe
172
+ with_native(__method__) do |native|
173
+ response = Rdkafka::Bindings.rd_kafka_share_unsubscribe(native)
174
+ Rdkafka::RdkafkaError.validate!(response)
175
+ end
176
+
177
+ nil
178
+ end
179
+
180
+ # Returns the current subscription to topics.
181
+ #
182
+ # @return [Rdkafka::Consumer::TopicPartitionList]
183
+ # @raise [RdkafkaError] When getting the subscription fails
184
+ def subscription
185
+ with_native(__method__) do |native|
186
+ ptr = FFI::MemoryPointer.new(:pointer)
187
+ response = Rdkafka::Bindings.rd_kafka_share_subscription(native, ptr)
188
+ Rdkafka::RdkafkaError.validate!(response)
189
+
190
+ native_tpl = ptr.read_pointer
191
+
192
+ begin
193
+ Rdkafka::Consumer::TopicPartitionList.from_native_tpl(native_tpl)
194
+ ensure
195
+ Rdkafka::Bindings.rd_kafka_topic_partition_list_destroy(native_tpl)
196
+ end
197
+ end
198
+ end
199
+
200
+ # Polls for a batch of messages.
201
+ #
202
+ # Unlike {Consumer#poll} a single call returns a whole batch (up to `max.poll.records`,
203
+ # which is a soft bound in the preview). In implicit acknowledgement mode every record
204
+ # delivered by the previous poll is acknowledged as accepted by this call. In explicit mode
205
+ # every previously delivered record must have been acknowledged via {#acknowledge}, otherwise
206
+ # this call raises.
207
+ #
208
+ # Record-level errors (for example an unauthorized topic) are delivered as messages with
209
+ # {ShareConsumer::Message#error} set rather than raised, since a batch can mix valid records
210
+ # and errors. Callers should check {ShareConsumer::Message#error?} before using the payload.
211
+ # Error entries do not need to be acknowledged.
212
+ #
213
+ # @param timeout_ms [Integer] Timeout of this poll
214
+ # @return [Array<ShareConsumer::Message, RdkafkaError>] polled messages, empty when the
215
+ # timeout expired without records. A message that fails to build (see below) is an
216
+ # `RdkafkaError` rather than a `ShareConsumer::Message`.
217
+ # @raise [RdkafkaError] When polling fails at the batch level (for example unacknowledged
218
+ # records in explicit mode)
219
+ def poll(timeout_ms = Defaults::SHARE_CONSUMER_POLL_TIMEOUT_MS)
220
+ with_native(__method__) do |native|
221
+ messages_ptr = FFI::MemoryPointer.new(:pointer)
222
+ error_ptr = Rdkafka::Bindings.rd_kafka_share_poll(native, timeout_ms, messages_ptr)
223
+
224
+ # Destroys the native error and raises when it carries an actual error
225
+ Rdkafka::RdkafkaError.validate!(error_ptr)
226
+
227
+ batch = messages_ptr.read_pointer
228
+
229
+ next [] if batch.null?
230
+
231
+ begin
232
+ count = Rdkafka::Bindings.rd_kafka_messages_count(batch)
233
+
234
+ Array.new(count) do |i|
235
+ message_ptr = Rdkafka::Bindings.rd_kafka_messages_get(batch, i)
236
+ native_message = Rdkafka::Bindings::Message.new(message_ptr)
237
+
238
+ # Only touch the error machinery for the rare record-level error entries
239
+ error = if native_message[:err] == Rdkafka::Bindings::RD_KAFKA_RESP_ERR_NO_ERROR
240
+ nil
241
+ else
242
+ Rdkafka::RdkafkaError.build(native_message)
243
+ end
244
+
245
+ begin
246
+ Message.new(
247
+ native_message,
248
+ delivery_count: Rdkafka::Bindings.rd_kafka_message_delivery_count(message_ptr),
249
+ error: error
250
+ )
251
+ rescue Rdkafka::RdkafkaError => e
252
+ # A message that fails to build (e.g. a header read error) is surfaced inline as an
253
+ # error rather than discarding the whole batch - including the messages already
254
+ # built - and raising, which would silently lose them. Mirrors how
255
+ # Consumer#poll_batch handles the same failure mode.
256
+ e
257
+ end
258
+ end
259
+ ensure
260
+ # Destroys the batch together with all the messages it contains. All message content
261
+ # has been copied into Ruby objects at this point.
262
+ Rdkafka::Bindings.rd_kafka_messages_destroy(batch)
263
+ end
264
+ end
265
+ end
266
+
267
+ # Acknowledges a message delivered by {#poll} in explicit acknowledgement mode
268
+ # (`share.acknowledgement.mode` set to `explicit`).
269
+ #
270
+ # Acknowledgements are accumulated locally and sent to the broker on the next {#poll},
271
+ # {#commit_sync} or {#commit_async}.
272
+ #
273
+ # @param message [ShareConsumer::Message] the message to acknowledge
274
+ # @param type [Symbol] `:accept` (processed successfully), `:release` (make it available for
275
+ # redelivery) or `:reject` (do not deliver again, archive)
276
+ # @return [nil]
277
+ # @raise [ArgumentError] For an unknown acknowledge type
278
+ # @raise [RdkafkaError] When acknowledging fails (for example in implicit mode, or for a
279
+ # record that is not currently acquired)
280
+ def acknowledge(message, type = :accept)
281
+ ack_type = ACKNOWLEDGE_TYPES.fetch(type) do
282
+ raise ArgumentError, "Unknown acknowledge type: #{type.inspect} (expected one of #{ACKNOWLEDGE_TYPES.keys.map(&:inspect).join(", ")})"
283
+ end
284
+
285
+ with_native(__method__) do |native|
286
+ response = Rdkafka::Bindings.rd_kafka_share_acknowledge_offset(
287
+ native,
288
+ message.topic,
289
+ message.partition,
290
+ message.offset,
291
+ ack_type
292
+ )
293
+
294
+ # This runs once per consumed message in explicit mode, so the error prefix is only
295
+ # built on the failure path
296
+ unless response == Rdkafka::Bindings::RD_KAFKA_RESP_ERR_NO_ERROR
297
+ Rdkafka::RdkafkaError.validate!(
298
+ response,
299
+ "Error acknowledging #{message.topic}/#{message.partition}@#{message.offset}"
300
+ )
301
+ end
302
+ end
303
+
304
+ nil
305
+ end
306
+
307
+ # Sends all pending acknowledgements to the broker and waits for the responses.
308
+ #
309
+ # @param timeout_ms [Integer] Maximum time to wait for the broker replies
310
+ # @return [Rdkafka::Consumer::TopicPartitionList, nil] per-partition results where each
311
+ # partition carries the acknowledgement outcome in its `err` field, or nil when there was
312
+ # nothing to commit
313
+ # @raise [RdkafkaError] When the commit fails as a whole
314
+ def commit_sync(timeout_ms = Defaults::SHARE_CONSUMER_COMMIT_SYNC_TIMEOUT_MS)
315
+ with_native(__method__) do |native|
316
+ tpl_ptr = FFI::MemoryPointer.new(:pointer)
317
+ error_ptr = Rdkafka::Bindings.rd_kafka_share_commit_sync(native, timeout_ms, tpl_ptr)
318
+
319
+ Rdkafka::RdkafkaError.validate!(error_ptr)
320
+
321
+ native_tpl = tpl_ptr.read_pointer
322
+
323
+ next nil if native_tpl.null?
324
+
325
+ begin
326
+ Rdkafka::Consumer::TopicPartitionList.from_native_tpl(native_tpl)
327
+ ensure
328
+ Rdkafka::Bindings.rd_kafka_topic_partition_list_destroy(native_tpl)
329
+ end
330
+ end
331
+ end
332
+
333
+ # Sends all pending acknowledgements to the broker without waiting for the responses.
334
+ #
335
+ # Outcomes are reported through the callback set with {#acknowledgement_commit_callback=}
336
+ # once the broker replies arrive (serviced by a later {#poll}).
337
+ #
338
+ # @return [nil]
339
+ # @raise [RdkafkaError] When enqueuing the commit fails
340
+ def commit_async
341
+ with_native(__method__) do |native|
342
+ error_ptr = Rdkafka::Bindings.rd_kafka_share_commit_async(native)
343
+
344
+ Rdkafka::RdkafkaError.validate!(error_ptr)
345
+ end
346
+
347
+ nil
348
+ end
349
+
350
+ # Sets the callback invoked with the outcome of acknowledgement commits, once per partition
351
+ # per commit, from within {#poll}/{#commit_sync}/{#commit_async} on the consumer thread.
352
+ #
353
+ # The callable is invoked with two arguments:
354
+ # - an Array of Hashes `{ topic: String, partition: Integer, offsets: Array<Integer> }`
355
+ # describing the acknowledged offsets
356
+ # - an RdkafkaError or nil with the outcome for those offsets
357
+ #
358
+ # @param callback [Proc, #call, nil] callable object or nil to clear the callback
359
+ # @return [nil]
360
+ # @raise [TypeError] When the callback is not callable
361
+ # @raise [RdkafkaError] When (de)registering the callback fails
362
+ #
363
+ # @note No share consumer methods may be called from within the callback (librdkafka rejects
364
+ # re-entry). Exceptions raised by the callback are logged and swallowed, mirroring the
365
+ # producer delivery callback behavior.
366
+ def acknowledgement_commit_callback=(callback)
367
+ raise TypeError.new("Callback has to be callable") unless callback.nil? || callback.respond_to?(:call)
368
+
369
+ function = callback && self.class.build_acknowledgement_commit_function(callback)
370
+
371
+ with_native(__method__) do |native|
372
+ error_ptr = Rdkafka::Bindings.rd_kafka_share_set_acknowledgement_commit_cb(
373
+ native,
374
+ function,
375
+ FFI::Pointer::NULL
376
+ )
377
+
378
+ Rdkafka::RdkafkaError.validate!(error_ptr)
379
+
380
+ # Retain the function only after successful registration, in the state holder shared
381
+ # with the finalizer: the finalizer-driven destroy can still invoke it while flushing
382
+ # pending acknowledgements, so it must outlive the consumer object itself
383
+ @state.ack_callback = function
384
+ end
385
+ end
386
+
387
+ # Builds the native acknowledgement commit callback function wrapping the given callable.
388
+ #
389
+ # Built in a class method on purpose: a Ruby block always closes over its `self`, and an
390
+ # instance-level block would chain finalizer -> state -> function -> block -> consumer,
391
+ # making a consumer with a registered callback permanently uncollectable. Here the captured
392
+ # `self` is the class and the block holds only the user callback.
393
+ #
394
+ # @private
395
+ # @param callback [#call] user callable
396
+ # @return [FFI::Function] native callback function
397
+ def self.build_acknowledgement_commit_function(callback)
398
+ FFI::Function.new(
399
+ :void, [:pointer, :pointer, :int, :pointer]
400
+ ) do |_share_ptr, list_ptr, err_code, _opaque|
401
+ callback.call(
402
+ partition_offsets_from_native(list_ptr),
403
+ Rdkafka::RdkafkaError.build(err_code) || nil
404
+ )
405
+ rescue Exception => err
406
+ Rdkafka::Config.logger.error("Unhandled exception in acknowledgement commit callback: #{err.class} - #{err.message}")
407
+ end
408
+ end
409
+
410
+ # Converts a native rd_kafka_share_partition_offsets_list_t into an Array of Hashes. The
411
+ # native list is owned by librdkafka for the duration of the callback and must not be
412
+ # retained or destroyed.
413
+ #
414
+ # @private
415
+ # @param list_ptr [FFI::Pointer] native partition offsets list
416
+ # @return [Array<Hash>] one Hash per partition with :topic, :partition and :offsets keys
417
+ def self.partition_offsets_from_native(list_ptr)
418
+ count = Rdkafka::Bindings.rd_kafka_share_partition_offsets_list_count(list_ptr)
419
+
420
+ Array.new(count) do |i|
421
+ entry_ptr = Rdkafka::Bindings.rd_kafka_share_partition_offsets_list_get(list_ptr, i)
422
+ partition = Rdkafka::Bindings::TopicPartition.new(
423
+ Rdkafka::Bindings.rd_kafka_share_partition_offsets_partition(entry_ptr)
424
+ )
425
+ offsets_cnt = Rdkafka::Bindings.rd_kafka_share_partition_offsets_offsets_cnt(entry_ptr)
426
+ offsets_ptr = Rdkafka::Bindings.rd_kafka_share_partition_offsets_offsets(entry_ptr)
427
+
428
+ {
429
+ topic: partition[:topic],
430
+ partition: partition[:partition],
431
+ offsets: offsets_ptr.null? ? [] : offsets_ptr.read_array_of_int64(offsets_cnt)
432
+ }
433
+ end
434
+ end
435
+
436
+ # Poll for messages repeatedly and yield them one by one. Iteration ends when the consumer
437
+ # is closed.
438
+ #
439
+ # As with {#poll}, an entry that fails to build is yielded as an `RdkafkaError` rather than a
440
+ # {ShareConsumer::Message}, so a block that touches message accessors should guard accordingly.
441
+ #
442
+ # @yield [message] a polled entry
443
+ # @yieldparam message [ShareConsumer::Message, RdkafkaError]
444
+ # @return [nil]
445
+ # @raise [RdkafkaError] When polling fails
446
+ def each
447
+ loop do
448
+ # Only a ClosedConsumerError raised by poll itself ends iteration; the yield is kept
449
+ # outside the rescue so an error raised from the caller's block propagates instead of
450
+ # being mistaken for the consumer closing.
451
+ batch =
452
+ begin
453
+ poll
454
+ rescue Rdkafka::ClosedConsumerError
455
+ break
456
+ end
457
+
458
+ batch.each { |message| yield(message) }
459
+ end
460
+ end
461
+
462
+ # Marks failed oauth token acquire in librdkafka for this share consumer
463
+ #
464
+ # @param reason [String] human readable error reason for failing to acquire token
465
+ def oauthbearer_set_token_failure(reason)
466
+ with_native(__method__) do |native|
467
+ Rdkafka::Bindings.rd_kafka_share_oauthbearer_set_token_failure(native, reason)
468
+ end
469
+ end
470
+
471
+ # Whether this share consumer has closed. Also reports closed in any process other than the
472
+ # one that created it: librdkafka is not fork-safe, so an inherited handle must be treated as
473
+ # unusable (and never torn down) in a forked child.
474
+ # @return [Boolean]
475
+ def closed?
476
+ @state.native.nil? || @state.creator_pid != Process.pid
477
+ end
478
+
479
+ # Closes this share consumer: sends any pending acknowledgements, leaves the share group and
480
+ # frees the native handle. Waits for in-flight calls from other threads to finish first.
481
+ # Close is bounded internally by `socket.timeout.ms` (there is no timeout argument in the
482
+ # librdkafka preview).
483
+ #
484
+ # @return [nil]
485
+ # @raise [RdkafkaError] When closing or destroying reported an error. librdkafka leaves the
486
+ # instance intact in that case, so the consumer keeps its native handle and `close` can be
487
+ # retried (for example after returning from the acknowledgement commit callback, from
488
+ # within which closing is rejected).
489
+ def close
490
+ return if closed?
491
+
492
+ @access_mutex.synchronize do
493
+ # Wait for in-flight calls on other threads to finish using the handle. New calls block
494
+ # on the mutex above while we hold it.
495
+ sleep(Defaults::NATIVE_KAFKA_SYNCHRONIZE_SLEEP_INTERVAL_MS / 1_000.0) until @operations_in_progress.zero?
496
+
497
+ native = @state.native
498
+
499
+ # Lost the race against a concurrent close that already finished
500
+ return if native.nil?
501
+
502
+ # librdkafka leaves the instance intact when close/destroy fail, and validate! raises
503
+ # before any reference (or the finalizer) is dropped, so the caller can retry close
504
+ # and the handle cannot leak silently.
505
+ #
506
+ # The close step is skipped when librdkafka already considers the consumer closed
507
+ # (e.g. a prior close attempt succeeded but its destroy failed and was retried).
508
+ if Rdkafka::Bindings.rd_kafka_share_consumer_closed(native).zero?
509
+ Rdkafka::RdkafkaError.validate!(Rdkafka::Bindings.rd_kafka_share_consumer_close(native))
510
+ end
511
+
512
+ Rdkafka::RdkafkaError.validate!(Rdkafka::Bindings.rd_kafka_share_destroy(native))
513
+
514
+ # The native handle is gone only now; drop all references and the finalizer
515
+ ObjectSpace.undefine_finalizer(self)
516
+ @state.native = nil
517
+ @state.ack_callback = nil
518
+ end
519
+
520
+ nil
521
+ end
522
+
523
+ private
524
+
525
+ # Executes a block with the native share handle while tracking the call so {#close} cannot
526
+ # tear the handle down mid-call from another thread. Increments happen under the access
527
+ # mutex that close holds for the whole teardown, so once close is draining no new call can
528
+ # slip in; librdkafka's own single-owner gate still rejects genuinely concurrent API calls.
529
+ #
530
+ # @param method [Symbol] public method name, used for the ClosedConsumerError
531
+ # @yield [FFI::Pointer] the native share handle
532
+ # @raise [ClosedConsumerError] when the consumer is closed or closing
533
+ def with_native(method)
534
+ # Owning the mutex here means re-entry from a callback fired during #close's teardown
535
+ # (the only internal holder) - the handle is going away, so reject before tracking
536
+ raise Rdkafka::ClosedConsumerError.new(method) if @access_mutex.owned?
537
+
538
+ @access_mutex.synchronize { @operations_in_progress += 1 }
539
+
540
+ begin
541
+ native = @state.native
542
+
543
+ # nil handle => closed; different pid => inherited by a forked child, where librdkafka is
544
+ # not fork-safe and the handle must not be used.
545
+ raise Rdkafka::ClosedConsumerError.new(method) if native.nil? || @state.creator_pid != Process.pid
546
+
547
+ yield(native)
548
+ ensure
549
+ @decrement_mutex.synchronize { @operations_in_progress -= 1 }
550
+ end
551
+ end
552
+
553
+ # Performs the share-specific native token set call, reusing the buffer and extension
554
+ # plumbing from {Helpers::OAuth}
555
+ #
556
+ # @param token [String] the token value
557
+ # @param lifetime_ms [Integer] token expiry, in milliseconds since the epoch
558
+ # @param principal_name [String] the Kafka principal name associated with the token
559
+ # @param extensions_ptr [FFI::Pointer, nil] `const char **` built by
560
+ # {Helpers::OAuth#map_extensions}
561
+ # @param extensions_size [Integer] number of key-value pairs pointed to by `extensions_ptr`
562
+ # @param error_buffer [FFI::MemoryPointer] 256-byte buffer for the error string
563
+ # @return [Integer] 0 on success
564
+ def oauthbearer_native_set_token(token, lifetime_ms, principal_name, extensions_ptr, extensions_size, error_buffer)
565
+ with_native(:oauthbearer_set_token) do |native|
566
+ Rdkafka::Bindings.rd_kafka_share_oauthbearer_set_token(
567
+ native, token, lifetime_ms, principal_name,
568
+ extensions_ptr, extensions_size, error_buffer, 256
569
+ )
570
+ end
571
+ end
572
+ end
573
+ end
@@ -2,9 +2,9 @@
2
2
 
3
3
  module Rdkafka
4
4
  # Current rdkafka-ruby gem version
5
- VERSION = "0.29.0"
5
+ VERSION = "0.30.1"
6
6
  # Target librdkafka version to be used
7
- LIBRDKAFKA_VERSION = "2.15.0"
7
+ LIBRDKAFKA_VERSION = "2.15.1"
8
8
  # SHA256 hash of the librdkafka source tarball for verification
9
- LIBRDKAFKA_SOURCE_SHA256 = "259015220cdca708afe838b5aa79ebf1a5fb710fb4179cf918d390aed85d5dbc"
9
+ LIBRDKAFKA_SOURCE_SHA256 = "23c8575c7d1ced07246cb9cf200c11325b72201fd4134a02414ca869fbdd8ed3"
10
10
  end
data/lib/rdkafka.rb CHANGED
@@ -13,6 +13,10 @@ require "rdkafka/helpers/metadata"
13
13
  require "rdkafka/helpers/list_offsets"
14
14
  require "rdkafka/abstract_handle"
15
15
  require "rdkafka/admin"
16
+ require "rdkafka/admin/alter_consumer_group_offsets_handle"
17
+ require "rdkafka/admin/alter_consumer_group_offsets_report"
18
+ require "rdkafka/admin/delete_consumer_group_offsets_handle"
19
+ require "rdkafka/admin/delete_consumer_group_offsets_report"
16
20
  require "rdkafka/admin/create_topic_handle"
17
21
  require "rdkafka/admin/create_topic_report"
18
22
  require "rdkafka/admin/delete_groups_handle"
@@ -46,6 +50,8 @@ require "rdkafka/callbacks/base_handler"
46
50
  require "rdkafka/callbacks/create_topic_handler"
47
51
  require "rdkafka/callbacks/delete_topic_handler"
48
52
  require "rdkafka/callbacks/create_partitions_handler"
53
+ require "rdkafka/callbacks/alter_consumer_group_offsets_handler"
54
+ require "rdkafka/callbacks/delete_consumer_group_offsets_handler"
49
55
  require "rdkafka/callbacks/delete_groups_handler"
50
56
  require "rdkafka/callbacks/delete_records_handler"
51
57
  require "rdkafka/callbacks/create_acl_handler"
@@ -62,6 +68,8 @@ require "rdkafka/consumer/message"
62
68
  require "rdkafka/consumer/partition"
63
69
  require "rdkafka/consumer/allocator"
64
70
  require "rdkafka/consumer/topic_partition_list"
71
+ require "rdkafka/share_consumer"
72
+ require "rdkafka/share_consumer/message"
65
73
  require "rdkafka/error"
66
74
  require "rdkafka/metadata"
67
75
  require "rdkafka/native_kafka"