karafka-rdkafka 0.27.2-aarch64-linux-gnu → 0.28.0-aarch64-linux-gnu

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +33 -0
  3. data/README.md +1 -0
  4. data/docker-compose-ssl.yml +1 -1
  5. data/docker-compose.yml +1 -1
  6. data/ext/librdkafka.so +0 -0
  7. data/karafka-rdkafka.gemspec +1 -0
  8. data/lib/rdkafka/abstract_handle.rb +37 -4
  9. data/lib/rdkafka/admin/config_binding_result.rb +1 -1
  10. data/lib/rdkafka/admin/create_acl_handle.rb +2 -8
  11. data/lib/rdkafka/admin/create_partitions_handle.rb +1 -20
  12. data/lib/rdkafka/admin/create_topic_handle.rb +1 -20
  13. data/lib/rdkafka/admin/delete_acl_handle.rb +2 -10
  14. data/lib/rdkafka/admin/delete_groups_handle.rb +3 -11
  15. data/lib/rdkafka/admin/delete_topic_handle.rb +1 -20
  16. data/lib/rdkafka/admin/describe_acl_handle.rb +2 -10
  17. data/lib/rdkafka/admin/describe_configs_handle.rb +2 -13
  18. data/lib/rdkafka/admin/describe_configs_report.rb +5 -6
  19. data/lib/rdkafka/admin/incremental_alter_configs_handle.rb +2 -13
  20. data/lib/rdkafka/admin/incremental_alter_configs_report.rb +5 -6
  21. data/lib/rdkafka/admin/list_offsets_handle.rb +2 -13
  22. data/lib/rdkafka/admin/list_offsets_report.rb +5 -2
  23. data/lib/rdkafka/admin.rb +56 -137
  24. data/lib/rdkafka/bindings.rb +12 -3
  25. data/lib/rdkafka/callbacks/base_handler.rb +62 -0
  26. data/lib/rdkafka/callbacks/create_acl_handler.rb +37 -0
  27. data/lib/rdkafka/callbacks/create_partitions_handler.rb +37 -0
  28. data/lib/rdkafka/callbacks/create_topic_handler.rb +37 -0
  29. data/lib/rdkafka/callbacks/delete_acl_handler.rb +42 -0
  30. data/lib/rdkafka/callbacks/delete_groups_handler.rb +37 -0
  31. data/lib/rdkafka/callbacks/delete_topic_handler.rb +37 -0
  32. data/lib/rdkafka/callbacks/describe_acl_handler.rb +35 -0
  33. data/lib/rdkafka/callbacks/describe_configs_handler.rb +42 -0
  34. data/lib/rdkafka/callbacks/incremental_alter_configs_handler.rb +42 -0
  35. data/lib/rdkafka/callbacks/list_offsets_handler.rb +42 -0
  36. data/lib/rdkafka/callbacks.rb +56 -244
  37. data/lib/rdkafka/config.rb +50 -33
  38. data/lib/rdkafka/consumer/headers.rb +19 -5
  39. data/lib/rdkafka/consumer/topic_partition_list.rb +43 -33
  40. data/lib/rdkafka/consumer.rb +164 -23
  41. data/lib/rdkafka/defaults.rb +21 -1
  42. data/lib/rdkafka/error.rb +12 -4
  43. data/lib/rdkafka/helpers/list_offsets.rb +127 -0
  44. data/lib/rdkafka/helpers/metadata.rb +29 -0
  45. data/lib/rdkafka/metadata.rb +84 -19
  46. data/lib/rdkafka/producer/delivery_handle.rb +3 -3
  47. data/lib/rdkafka/producer/partitions_count_cache.rb +24 -38
  48. data/lib/rdkafka/producer.rb +72 -51
  49. data/lib/rdkafka/version.rb +3 -3
  50. data/lib/rdkafka.rb +13 -0
  51. data/package-lock.json +6 -6
  52. data/renovate.json +11 -1
  53. metadata +14 -4
  54. data/Gemfile +0 -13
  55. data/Gemfile.lint +0 -14
  56. data/Gemfile.lint.lock +0 -123
@@ -136,51 +136,61 @@ module Rdkafka
136
136
 
137
137
  # Create a native tpl with the contents of this object added.
138
138
  #
139
- # The pointer will be cleaned by `rd_kafka_topic_partition_list_destroy` when GC releases it.
139
+ # The returned pointer is caller-owned and must be released with
140
+ # `rd_kafka_topic_partition_list_destroy`. It is not cleaned up by GC.
140
141
  #
141
142
  # @private
142
143
  # @return [FFI::Pointer]
143
144
  def to_native_tpl
144
145
  tpl = Rdkafka::Bindings.rd_kafka_topic_partition_list_new(count)
145
146
 
146
- @data.each do |topic, partitions|
147
- if partitions
148
- partitions.each do |p|
149
- ref = Rdkafka::Bindings.rd_kafka_topic_partition_list_add(
150
- tpl,
151
- topic,
152
- p.partition
153
- )
154
-
155
- # Remove the respond to check after karafka 2.3.0 is released
156
- if p.respond_to?(:metadata) && p.metadata
157
- part = Rdkafka::Bindings::TopicPartition.new(ref)
158
- str_ptr = FFI::MemoryPointer.from_string(p.metadata)
159
- # released here:
160
- # https://github.com/confluentinc/librdkafka/blob/e03d3bb91ed92a38f38d9806b8d8deffe78a1de5/src/rdkafka_partition.c#L2682C18-L2682C18
161
- str_ptr.autorelease = false
162
- part[:metadata] = str_ptr
163
- part[:metadata_size] = p.metadata.bytesize
164
- end
165
-
166
- if p.offset
167
- offset = p.offset.is_a?(Time) ? p.offset.to_f * 1_000 : p.offset
168
-
169
- Rdkafka::Bindings.rd_kafka_topic_partition_list_set_offset(
147
+ begin
148
+ @data.each do |topic, partitions|
149
+ if partitions
150
+ partitions.each do |p|
151
+ ref = Rdkafka::Bindings.rd_kafka_topic_partition_list_add(
170
152
  tpl,
171
153
  topic,
172
- p.partition,
173
- offset
154
+ p.partition
174
155
  )
156
+
157
+ # Remove the respond to check after karafka 2.3.0 is released
158
+ if p.respond_to?(:metadata) && p.metadata
159
+ part = Rdkafka::Bindings::TopicPartition.new(ref)
160
+ str_ptr = FFI::MemoryPointer.from_string(p.metadata)
161
+ # released here:
162
+ # https://github.com/confluentinc/librdkafka/blob/e03d3bb91ed92a38f38d9806b8d8deffe78a1de5/src/rdkafka_partition.c#L2682C18-L2682C18
163
+ str_ptr.autorelease = false
164
+ part[:metadata] = str_ptr
165
+ part[:metadata_size] = p.metadata.bytesize
166
+ end
167
+
168
+ if p.offset
169
+ offset = p.offset.is_a?(Time) ? p.offset.to_f * 1_000 : p.offset
170
+
171
+ Rdkafka::Bindings.rd_kafka_topic_partition_list_set_offset(
172
+ tpl,
173
+ topic,
174
+ p.partition,
175
+ offset
176
+ )
177
+ end
175
178
  end
179
+ else
180
+ Rdkafka::Bindings.rd_kafka_topic_partition_list_add(
181
+ tpl,
182
+ topic,
183
+ Rdkafka::Bindings::RD_KAFKA_PARTITION_UA
184
+ )
176
185
  end
177
- else
178
- Rdkafka::Bindings.rd_kafka_topic_partition_list_add(
179
- tpl,
180
- topic,
181
- Rdkafka::Bindings::RD_KAFKA_PARTITION_UA
182
- )
183
186
  end
187
+ rescue Exception
188
+ # The native list is caller-owned and only handed back on success. If population raises
189
+ # partway (e.g. non-string metadata, or an offset FFI can't coerce to int64) we must
190
+ # destroy it here, otherwise the half-built list leaks.
191
+ Rdkafka::Bindings.rd_kafka_topic_partition_list_destroy(tpl)
192
+
193
+ raise
184
194
  end
185
195
 
186
196
  tpl
@@ -14,14 +14,46 @@ module Rdkafka
14
14
  include Enumerable
15
15
  include Helpers::Time
16
16
  include Helpers::OAuth
17
+ include Helpers::Metadata
18
+ include Helpers::ListOffsets
17
19
 
18
20
  # @private
19
21
  # @param native_kafka [NativeKafka] wrapper around the native Kafka consumer handle
20
22
  def initialize(native_kafka)
21
23
  @native_kafka = native_kafka
24
+ # Single-element holder shared with the GC finalizer so it can destroy the lazily created
25
+ # consumer queue without capturing `self` (capturing the consumer in its own finalizer would
26
+ # pin it and prevent it from ever being collected).
27
+ @consumer_queue_holder = []
28
+
29
+ # Makes sure the consumer is closed (consumer queue destroyed and native client destroyed)
30
+ # before it gets GCed by Ruby.
31
+ ObjectSpace.define_finalizer(self, self.class.finalizer(native_kafka, @consumer_queue_holder))
32
+ end
22
33
 
23
- # Makes sure, that native kafka gets closed before it gets GCed by Ruby
24
- ObjectSpace.define_finalizer(self, native_kafka.finalizer)
34
+ # Builds the GC finalizer for a consumer. It mirrors {#close}: close the consumer, destroy the
35
+ # consumer-queue reference, then destroy the native client. The default `NativeKafka#finalizer`
36
+ # went straight to `rd_kafka_destroy`, leaving the consumer-queue reference (from
37
+ # `rd_kafka_queue_get_consumer`, taken by `poll_batch`) dangling - which can make
38
+ # `rd_kafka_destroy` block inside the finalizer (process hang at GC/shutdown) or leak the handle.
39
+ #
40
+ # @private
41
+ # @param native_kafka [NativeKafka] the wrapped native client
42
+ # @param queue_holder [Array] single-element holder carrying the consumer queue pointer (or empty)
43
+ # @return [Proc] finalizer proc that must not reference the consumer instance
44
+ def self.finalizer(native_kafka, queue_holder)
45
+ proc do
46
+ next if native_kafka.closed?
47
+
48
+ native_kafka.synchronize do |inner|
49
+ Rdkafka::Bindings.rd_kafka_consumer_close(inner)
50
+
51
+ queue = queue_holder[0]
52
+ Rdkafka::Bindings.rd_kafka_queue_destroy(queue) if queue
53
+ end
54
+
55
+ native_kafka.close
56
+ end
25
57
  end
26
58
 
27
59
  # Starts the native Kafka polling thread and kicks off the init polling
@@ -187,6 +219,7 @@ module Rdkafka
187
219
  if @consumer_queue
188
220
  Rdkafka::Bindings.rd_kafka_queue_destroy(@consumer_queue)
189
221
  @consumer_queue = nil
222
+ @consumer_queue_holder[0] = nil
190
223
  end
191
224
  end
192
225
 
@@ -412,6 +445,8 @@ module Rdkafka
412
445
  #
413
446
  # @raise [RdkafkaError] When getting the positions fails.
414
447
  def position(list = nil)
448
+ closed_consumer_check(__method__)
449
+
415
450
  if list.nil?
416
451
  list = assignment
417
452
  elsif !list.is_a?(TopicPartitionList)
@@ -466,39 +501,77 @@ module Rdkafka
466
501
  # possible to create one yourself, in this case you have to provide a list that
467
502
  # already contains all the partitions you need the lag for.
468
503
  #
504
+ # The end offsets of all requested partitions are fetched in a single batched
505
+ # {#list_offsets} query - librdkafka fans it out to the involved partition leaders
506
+ # internally and concurrently - instead of one blocking {#query_watermark_offsets}
507
+ # broker roundtrip per partition.
508
+ #
469
509
  # @param topic_partition_list [TopicPartitionList] The list to calculate lag for.
470
- # @param watermark_timeout_ms [Integer] The timeout for each query watermark call.
510
+ # @param watermark_timeout_ms [Integer] The timeout for the batched end-offsets query.
471
511
  # @return [Hash{String => Hash{Integer => Integer}}] A hash containing all topics with the lag
472
512
  # per partition
513
+ # @raise [ClosedConsumerError] when the consumer is closed
473
514
  # @raise [RdkafkaError] When querying the broker fails.
474
515
  def lag(topic_partition_list, watermark_timeout_ms = Defaults::CONSUMER_LAG_TIMEOUT_MS)
516
+ closed_consumer_check(__method__)
517
+
475
518
  out = {}
519
+ request = {}
520
+ partitions_by_topic = topic_partition_list.to_h
521
+
522
+ partitions_by_topic.each do |topic, partitions|
523
+ out[topic] = {}
476
524
 
477
- topic_partition_list.to_h.each do |topic, partitions|
478
- # Query high watermarks for this topic's partitions
479
- # and compare to the offset in the list.
480
- topic_out = {}
481
525
  partitions.each do |p|
482
526
  next if p.offset.nil?
483
- _low, high = query_watermark_offsets(
484
- topic,
485
- p.partition,
486
- watermark_timeout_ms
487
- )
488
- topic_out[p.partition] = high - p.offset
527
+
528
+ (request[topic] ||= []) << { partition: p.partition, offset: :latest }
489
529
  end
490
- out[topic] = topic_out
491
530
  end
531
+
532
+ return out if request.empty?
533
+
534
+ report = begin
535
+ # The isolation level is forwarded so the end offsets match what the old per-partition
536
+ # watermark query returned: librdkafka resolves that query with the consumer's configured
537
+ # `isolation.level` (LSO for the default read_committed), while the admin-style batched
538
+ # query would otherwise default to read_uncommitted (true high watermark).
539
+ list_offsets(request, isolation_level: isolation_level)
540
+ .wait(max_wait_timeout_ms: watermark_timeout_ms)
541
+ rescue AbstractHandle::WaitTimeoutError
542
+ # Keep the pre-batching contract: a slow broker surfaced as a timed-out RdkafkaError
543
+ # from the per-partition watermark query, not as a handle wait timeout.
544
+ raise RdkafkaError.new(
545
+ Rdkafka::Bindings::RD_KAFKA_RESP_ERR__TIMED_OUT,
546
+ "Error querying watermark offsets of '#{request.keys.join(", ")}'"
547
+ )
548
+ end
549
+
550
+ end_offsets = {}
551
+ report.offsets.each do |result|
552
+ (end_offsets[result[:topic]] ||= {})[result[:partition]] = result[:offset]
553
+ end
554
+
555
+ partitions_by_topic.each do |topic, partitions|
556
+ partitions.each do |p|
557
+ next if p.offset.nil?
558
+
559
+ out[topic][p.partition] = end_offsets.fetch(topic).fetch(p.partition) - p.offset
560
+ end
561
+ end
562
+
492
563
  out
493
564
  end
494
565
 
495
566
  # Returns the ClusterId as reported in broker metadata.
496
567
  #
497
- # @return [String, nil]
498
- def cluster_id
568
+ # @param timeout_ms [Integer] how long to block waiting for metadata when the cluster id is
569
+ # not already cached (0 for a non-blocking call)
570
+ # @return [String, nil] the cluster id, or nil if it could not be retrieved in time
571
+ def cluster_id(timeout_ms = Defaults::CONSUMER_CLUSTER_ID_TIMEOUT_MS)
499
572
  closed_consumer_check(__method__)
500
573
  @native_kafka.with_inner do |inner|
501
- Rdkafka::Bindings.rd_kafka_clusterid(inner)
574
+ read_and_free_native_string(inner, Rdkafka::Bindings.rd_kafka_clusterid(inner, timeout_ms))
502
575
  end
503
576
  end
504
577
 
@@ -510,7 +583,7 @@ module Rdkafka
510
583
  def member_id
511
584
  closed_consumer_check(__method__)
512
585
  @native_kafka.with_inner do |inner|
513
- Rdkafka::Bindings.rd_kafka_memberid(inner)
586
+ read_and_free_native_string(inner, Rdkafka::Bindings.rd_kafka_memberid(inner))
514
587
  end
515
588
  end
516
589
 
@@ -847,8 +920,17 @@ module Rdkafka
847
920
  next
848
921
  end
849
922
 
850
- results << Rdkafka::Consumer::Message.new(native_message)
851
- Rdkafka::Bindings.rd_kafka_message_destroy(ptr)
923
+ begin
924
+ results << Rdkafka::Consumer::Message.new(native_message)
925
+ rescue Rdkafka::RdkafkaError => e
926
+ # A message that fails to build (e.g. a header read error) is surfaced inline as an
927
+ # error event rather than discarding the whole batch - including the messages already
928
+ # built - and raising, which silently lost them once their offsets had been stored.
929
+ results << e
930
+ ensure
931
+ Rdkafka::Bindings.rd_kafka_message_destroy(ptr)
932
+ end
933
+
852
934
  i += 1
853
935
  end
854
936
  ensure
@@ -913,8 +995,17 @@ module Rdkafka
913
995
  next
914
996
  end
915
997
 
916
- results << Rdkafka::Consumer::Message.new(native_message)
917
- Rdkafka::Bindings.rd_kafka_message_destroy(ptr)
998
+ begin
999
+ results << Rdkafka::Consumer::Message.new(native_message)
1000
+ rescue Rdkafka::RdkafkaError => e
1001
+ # A message that fails to build (e.g. a header read error) is surfaced inline as an
1002
+ # error event rather than discarding the whole batch - including the messages already
1003
+ # built - and raising, which silently lost them once their offsets had been stored.
1004
+ results << e
1005
+ ensure
1006
+ Rdkafka::Bindings.rd_kafka_message_destroy(ptr)
1007
+ end
1008
+
918
1009
  i += 1
919
1010
  end
920
1011
  ensure
@@ -994,18 +1085,68 @@ module Rdkafka
994
1085
 
995
1086
  private
996
1087
 
1088
+ # Copies a librdkafka-allocated C string into a Ruby string and frees the native buffer.
1089
+ #
1090
+ # `rd_kafka_memberid`/`rd_kafka_clusterid` return a string the caller owns and must release
1091
+ # with `rd_kafka_mem_free`; without this the buffer leaks on every call.
1092
+ #
1093
+ # @param inner [FFI::Pointer] the native client handle (needed to free the buffer)
1094
+ # @param ptr [FFI::Pointer] the librdkafka-allocated string, possibly NULL
1095
+ # @return [String, nil] the copied string, or nil when the pointer was NULL
1096
+ def read_and_free_native_string(inner, ptr)
1097
+ return nil if ptr.null?
1098
+
1099
+ ptr.read_string
1100
+ ensure
1101
+ Rdkafka::Bindings.rd_kafka_mem_free(inner, ptr) unless ptr.null?
1102
+ end
1103
+
997
1104
  # Checks if the consumer is closed and raises an error if so
998
1105
  # @param method [Symbol] name of the calling method for error context
999
1106
  # @raise [ClosedConsumerError] when the consumer is closed
1000
1107
  def closed_consumer_check(method)
1001
1108
  raise Rdkafka::ClosedConsumerError.new(method) if closed?
1002
1109
  end
1110
+ alias_method :closed_check, :closed_consumer_check
1111
+
1112
+ # Reads this consumer's effective `isolation.level` from the live librdkafka configuration
1113
+ # and maps it to the numeric isolation level constant. Memoized: the value cannot change
1114
+ # after client creation.
1115
+ #
1116
+ # @return [Integer] `RD_KAFKA_ISOLATION_LEVEL_READ_COMMITTED` or
1117
+ # `RD_KAFKA_ISOLATION_LEVEL_READ_UNCOMMITTED`
1118
+ # @raise [Rdkafka::Config::ConfigError] when the property cannot be read
1119
+ def isolation_level
1120
+ @isolation_level ||= @native_kafka.with_inner do |inner|
1121
+ conf = Rdkafka::Bindings.rd_kafka_conf(inner)
1122
+
1123
+ size_ptr = Rdkafka::Bindings::SizePtr.new
1124
+ size_ptr[:value] = 64
1125
+ value_ptr = FFI::MemoryPointer.new(:char, 64)
1126
+
1127
+ result = Rdkafka::Bindings.rd_kafka_conf_get(conf, "isolation.level", value_ptr, size_ptr)
1128
+
1129
+ if result != :config_ok
1130
+ raise Rdkafka::Config::ConfigError.new("Could not read isolation.level: #{result}")
1131
+ end
1132
+
1133
+ if value_ptr.read_string == "read_committed"
1134
+ Rdkafka::Bindings::RD_KAFKA_ISOLATION_LEVEL_READ_COMMITTED
1135
+ else
1136
+ Rdkafka::Bindings::RD_KAFKA_ISOLATION_LEVEL_READ_UNCOMMITTED
1137
+ end
1138
+ end
1139
+ end
1003
1140
 
1004
1141
  # Returns the consumer queue pointer, lazily initialized
1005
1142
  # @return [FFI::Pointer] consumer queue handle
1006
1143
  def consumer_queue
1007
1144
  @consumer_queue ||= @native_kafka.with_inner do |inner|
1008
- Rdkafka::Bindings.rd_kafka_queue_get_consumer(inner)
1145
+ queue = Rdkafka::Bindings.rd_kafka_queue_get_consumer(inner)
1146
+ # Share the pointer with the finalizer so it is destroyed even if the consumer is GC'd
1147
+ # without an explicit close.
1148
+ @consumer_queue_holder[0] = queue
1149
+ queue
1009
1150
  end
1010
1151
  end
1011
1152
 
@@ -22,6 +22,10 @@ module Rdkafka
22
22
  # @see Consumer#offsets_for_times
23
23
  CONSUMER_OFFSETS_FOR_TIMES_TIMEOUT_MS = 1_000
24
24
 
25
+ # Consumer timeout for fetching the cluster id when it is not already cached from metadata
26
+ # @see Consumer#cluster_id
27
+ CONSUMER_CLUSTER_ID_TIMEOUT_MS = 1_000
28
+
25
29
  # Consumer timeout for poll operations (used in each iteration)
26
30
  # @see Consumer#each
27
31
  CONSUMER_POLL_TIMEOUT_MS = 250
@@ -55,14 +59,30 @@ module Rdkafka
55
59
  # @see Admin#metadata
56
60
  METADATA_TIMEOUT_MS = 2_000
57
61
 
58
- # Maximum retries for metadata requests on transient errors
62
+ # Hard ceiling on metadata fetch attempts on transient errors (backstop; the retry budget
63
+ # normally ends the loop first)
59
64
  # @see Metadata#initialize
60
65
  METADATA_MAX_RETRIES = 10
61
66
 
67
+ # Minimum metadata fetch attempts before the retry budget may end the loop, so a slow broker
68
+ # (whose requests each consume the full timeout) still gets a few tries
69
+ # @see Metadata#initialize
70
+ METADATA_MIN_ATTEMPTS = 3
71
+
72
+ # Soft wall-clock budget for the whole metadata retry loop; past it (and past
73
+ # METADATA_MIN_ATTEMPTS) the loop stops so a synchronous fetch cannot block the caller for long
74
+ # @see Metadata#initialize
75
+ METADATA_RETRY_BUDGET_MS = 5_000
76
+
62
77
  # Base backoff time for metadata retry (100ms = 0.1s)
63
78
  # @see Metadata#initialize
64
79
  METADATA_RETRY_BACKOFF_BASE_MS = 100
65
80
 
81
+ # Maximum backoff time between metadata retries. Caps the exponential backoff so a long retry
82
+ # sequence against an unhealthy cluster cannot block the calling thread for minutes.
83
+ # @see Metadata#initialize
84
+ METADATA_RETRY_BACKOFF_MAX_MS = 1_000
85
+
66
86
  # Default wait timeout for operation handles
67
87
  # @see AbstractHandle#wait
68
88
  HANDLE_WAIT_TIMEOUT_MS = 60_000
data/lib/rdkafka/error.rb CHANGED
@@ -35,8 +35,9 @@ module Rdkafka
35
35
  # @param response_ptr [FFI::Pointer] Pointer to rd_kafka_error_t
36
36
  # @param message_prefix [String, nil] Optional prefix for the error message
37
37
  # @param broker_message [String, nil] Optional broker error message
38
+ # @param instance_name [String, nil] Optional name of the rdkafka instance
38
39
  # @return [RdkafkaError, false] Error instance or false if no error
39
- def build_from_c(response_ptr, message_prefix = nil, broker_message: nil)
40
+ def build_from_c(response_ptr, message_prefix = nil, broker_message: nil, instance_name: nil)
40
41
  code = Rdkafka::Bindings.rd_kafka_error_code(response_ptr)
41
42
 
42
43
  return false if code == Bindings::RD_KAFKA_RESP_ERR_NO_ERROR
@@ -54,7 +55,8 @@ module Rdkafka
54
55
  broker_message: message,
55
56
  fatal: fatal,
56
57
  retryable: retryable,
57
- abortable: abortable
58
+ abortable: abortable,
59
+ instance_name: instance_name
58
60
  )
59
61
  end
60
62
 
@@ -92,10 +94,16 @@ module Rdkafka
92
94
  response_ptr_or_code[:err],
93
95
  message_prefix,
94
96
  broker_message: broker_message,
95
- details: details
97
+ details: details,
98
+ instance_name: instance_name
96
99
  )
97
100
  else
98
- build_from_c(response_ptr_or_code, message_prefix)
101
+ build_from_c(
102
+ response_ptr_or_code,
103
+ message_prefix,
104
+ broker_message: broker_message,
105
+ instance_name: instance_name
106
+ )
99
107
  end
100
108
  end
101
109
 
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rdkafka
4
+ module Helpers
5
+ # Shared `#list_offsets` implementation for Admin and Consumer.
6
+ #
7
+ # The librdkafka Admin API is handle-agnostic: `rd_kafka_ListOffsets()` can be issued on any
8
+ # `rd_kafka_t` instance regardless of its type, so this Ruby-level implementation is identical
9
+ # for admin and consumer clients. Includers must provide a private `#closed_check(method)`
10
+ # that raises their own `Closed*Error`, and must have the background event callback
11
+ # registered on their native config (see `Config#admin` and `Config#consumer`) so the result
12
+ # event is dispatched back to the returned handle.
13
+ module ListOffsets
14
+ # Queries partition offsets by specification (earliest, latest, max_timestamp, or by
15
+ # timestamp) without requiring a consumer group.
16
+ #
17
+ # The query is batched: all requested partitions are carried in one `ListOffsets` request
18
+ # that librdkafka fans out to the involved partition leaders internally and concurrently.
19
+ #
20
+ # @param topic_partition_offsets [Hash{String => Array<Hash>}] hash mapping topic names to
21
+ # arrays of partition offset specifications. Each specification is a hash with:
22
+ # - `:partition` [Integer] partition number
23
+ # - `:offset` [Symbol, Integer] offset specification - `:earliest`, `:latest`,
24
+ # `:max_timestamp`, or an integer timestamp in milliseconds
25
+ # @param isolation_level [Integer, nil] optional isolation level:
26
+ # - `RD_KAFKA_ISOLATION_LEVEL_READ_UNCOMMITTED` (0) - default
27
+ # - `RD_KAFKA_ISOLATION_LEVEL_READ_COMMITTED` (1)
28
+ #
29
+ # @return [Admin::ListOffsetsHandle] handle that can be used to wait for the result
30
+ #
31
+ # @raise [ClosedAdminError, ClosedConsumerError] when the client is closed
32
+ # @raise [ConfigError] when the background queue is unavailable
33
+ #
34
+ # @example Query earliest and latest offsets
35
+ # handle = client.list_offsets(
36
+ # { "my_topic" => [
37
+ # { partition: 0, offset: :earliest },
38
+ # { partition: 1, offset: :latest }
39
+ # ] }
40
+ # )
41
+ # report = handle.wait(max_wait_timeout_ms: 15_000)
42
+ # report.offsets
43
+ # # => [{ topic: "my_topic", partition: 0, offset: 0, ... }, ...]
44
+ def list_offsets(topic_partition_offsets, isolation_level: nil)
45
+ closed_check(__method__)
46
+
47
+ # Parse and validate every offset spec before allocating the native list, so a missing key
48
+ # or an unknown offset specification raises with nothing to clean up. Previously the
49
+ # ArgumentError (or KeyError) was raised after `rd_kafka_topic_partition_list_new`, leaking
50
+ # the native list.
51
+ parsed = topic_partition_offsets.flat_map do |topic, partitions|
52
+ partitions.map do |spec|
53
+ offset = spec.fetch(:offset)
54
+
55
+ native_offset = case offset
56
+ when :earliest then Rdkafka::Bindings::RD_KAFKA_OFFSET_SPEC_EARLIEST
57
+ when :latest then Rdkafka::Bindings::RD_KAFKA_OFFSET_SPEC_LATEST
58
+ when :max_timestamp then Rdkafka::Bindings::RD_KAFKA_OFFSET_SPEC_MAX_TIMESTAMP
59
+ when Integer then offset
60
+ else
61
+ raise ArgumentError, "Unknown offset specification: #{offset.inspect}"
62
+ end
63
+
64
+ [topic, spec.fetch(:partition), native_offset]
65
+ end
66
+ end
67
+
68
+ # Build native topic partition list
69
+ tpl = Rdkafka::Bindings.rd_kafka_topic_partition_list_new(parsed.size)
70
+
71
+ parsed.each do |topic, partition, native_offset|
72
+ Rdkafka::Bindings.rd_kafka_topic_partition_list_add(tpl, topic, partition)
73
+ Rdkafka::Bindings.rd_kafka_topic_partition_list_set_offset(tpl, topic, partition, native_offset)
74
+ end
75
+
76
+ # Get a pointer to the queue that our request will be enqueued on
77
+ queue_ptr = @native_kafka.with_inner do |inner|
78
+ Rdkafka::Bindings.rd_kafka_queue_get_background(inner)
79
+ end
80
+
81
+ if queue_ptr.null?
82
+ Rdkafka::Bindings.rd_kafka_topic_partition_list_destroy(tpl)
83
+ raise Rdkafka::Config::ConfigError.new("rd_kafka_queue_get_background was NULL")
84
+ end
85
+
86
+ # Create and register the handle we will return to the caller
87
+ handle = Admin::ListOffsetsHandle.new
88
+ handle[:pending] = true
89
+ handle[:response] = Rdkafka::Bindings::RD_KAFKA_PARTITION_UA
90
+
91
+ admin_options_ptr = @native_kafka.with_inner do |inner|
92
+ Rdkafka::Bindings.rd_kafka_AdminOptions_new(
93
+ inner,
94
+ Rdkafka::Bindings::RD_KAFKA_ADMIN_OP_LISTOFFSETS
95
+ )
96
+ end
97
+
98
+ if isolation_level
99
+ Rdkafka::Bindings.rd_kafka_AdminOptions_set_isolation_level(admin_options_ptr, isolation_level)
100
+ end
101
+
102
+ Admin::ListOffsetsHandle.register(handle)
103
+ Rdkafka::Bindings.rd_kafka_AdminOptions_set_opaque(admin_options_ptr, handle.to_ptr)
104
+
105
+ begin
106
+ @native_kafka.with_inner do |inner|
107
+ Rdkafka::Bindings.rd_kafka_ListOffsets(
108
+ inner,
109
+ tpl,
110
+ admin_options_ptr,
111
+ queue_ptr
112
+ )
113
+ end
114
+ rescue Exception
115
+ Admin::ListOffsetsHandle.remove(handle.to_ptr.address)
116
+ raise
117
+ ensure
118
+ Rdkafka::Bindings.rd_kafka_AdminOptions_destroy(admin_options_ptr)
119
+ Rdkafka::Bindings.rd_kafka_queue_destroy(queue_ptr)
120
+ Rdkafka::Bindings.rd_kafka_topic_partition_list_destroy(tpl)
121
+ end
122
+
123
+ handle
124
+ end
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rdkafka
4
+ module Helpers
5
+ # Shared `#metadata` implementation for Admin, Consumer and Producer.
6
+ #
7
+ # `rd_kafka_metadata()` is handle-agnostic in librdkafka - it works on any `rd_kafka_t`
8
+ # (consumer, producer or admin) - so this Ruby-level implementation is identical across all
9
+ # three client types. Includers must provide a private `#closed_check(method)` that raises
10
+ # their own `Closed*Error`.
11
+ module Metadata
12
+ # Performs the metadata request using this client
13
+ #
14
+ # @param topic_name [String, nil] metadata about particular topic or all if nil
15
+ # @param timeout_ms [Integer] metadata request timeout
16
+ # @return [Rdkafka::Metadata] requested metadata
17
+ def metadata(topic_name = nil, timeout_ms = Defaults::METADATA_TIMEOUT_MS)
18
+ closed_check(__method__)
19
+
20
+ @native_kafka.with_inner do |inner|
21
+ # Must stay fully qualified: this module is itself named `Metadata`, so a bare
22
+ # `Metadata.new` here would resolve to `Rdkafka::Helpers::Metadata` (no `.new`) instead
23
+ # of this class.
24
+ Rdkafka::Metadata.new(inner, topic_name, timeout_ms)
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end