rdkafka 0.28.0 → 0.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +33 -0
  3. data/README.md +1 -0
  4. data/dist/{librdkafka-2.14.1.tar.gz → librdkafka-2.14.2.tar.gz} +0 -0
  5. data/docker-compose-ssl.yml +1 -1
  6. data/docker-compose.yml +1 -1
  7. data/lib/rdkafka/abstract_handle.rb +31 -2
  8. data/lib/rdkafka/admin/config_binding_result.rb +2 -2
  9. data/lib/rdkafka/admin/create_acl_handle.rb +6 -5
  10. data/lib/rdkafka/admin/create_partitions_handle.rb +4 -6
  11. data/lib/rdkafka/admin/create_topic_handle.rb +4 -6
  12. data/lib/rdkafka/admin/delete_acl_handle.rb +5 -7
  13. data/lib/rdkafka/admin/delete_groups_handle.rb +4 -6
  14. data/lib/rdkafka/admin/delete_topic_handle.rb +4 -6
  15. data/lib/rdkafka/admin/describe_acl_handle.rb +5 -7
  16. data/lib/rdkafka/admin/describe_configs_handle.rb +4 -10
  17. data/lib/rdkafka/admin/describe_configs_report.rb +1 -5
  18. data/lib/rdkafka/admin/incremental_alter_configs_handle.rb +4 -10
  19. data/lib/rdkafka/admin/incremental_alter_configs_report.rb +1 -5
  20. data/lib/rdkafka/admin/list_offsets_handle.rb +5 -10
  21. data/lib/rdkafka/admin.rb +75 -43
  22. data/lib/rdkafka/bindings.rb +9 -4
  23. data/lib/rdkafka/callbacks/base_handler.rb +62 -0
  24. data/lib/rdkafka/callbacks/create_acl_handler.rb +37 -0
  25. data/lib/rdkafka/callbacks/create_partitions_handler.rb +37 -0
  26. data/lib/rdkafka/callbacks/create_topic_handler.rb +37 -0
  27. data/lib/rdkafka/callbacks/delete_acl_handler.rb +42 -0
  28. data/lib/rdkafka/callbacks/delete_groups_handler.rb +37 -0
  29. data/lib/rdkafka/callbacks/delete_topic_handler.rb +37 -0
  30. data/lib/rdkafka/callbacks/describe_acl_handler.rb +35 -0
  31. data/lib/rdkafka/callbacks/describe_configs_handler.rb +42 -0
  32. data/lib/rdkafka/callbacks/incremental_alter_configs_handler.rb +42 -0
  33. data/lib/rdkafka/callbacks/list_offsets_handler.rb +42 -0
  34. data/lib/rdkafka/callbacks.rb +50 -244
  35. data/lib/rdkafka/config.rb +45 -33
  36. data/lib/rdkafka/consumer/headers.rb +19 -5
  37. data/lib/rdkafka/consumer/partition.rb +8 -1
  38. data/lib/rdkafka/consumer/topic_partition_list.rb +44 -22
  39. data/lib/rdkafka/consumer.rb +111 -21
  40. data/lib/rdkafka/defaults.rb +19 -0
  41. data/lib/rdkafka/helpers/metadata.rb +29 -0
  42. data/lib/rdkafka/metadata.rb +86 -19
  43. data/lib/rdkafka/native_kafka.rb +2 -4
  44. data/lib/rdkafka/producer/delivery_handle.rb +3 -3
  45. data/lib/rdkafka/producer/partitions_count_cache.rb +24 -38
  46. data/lib/rdkafka/producer.rb +56 -44
  47. data/lib/rdkafka/version.rb +3 -3
  48. data/lib/rdkafka.rb +12 -0
  49. data/package-lock.json +3 -3
  50. data/rdkafka.gemspec +1 -0
  51. metadata +14 -5
  52. data/Gemfile +0 -13
  53. data/Gemfile.lint +0 -14
  54. data/Gemfile.lint.lock +0 -123
@@ -118,7 +118,12 @@ module Rdkafka
118
118
  else
119
119
  elem[:offset]
120
120
  end
121
- partition = Partition.new(elem[:partition], offset, elem[:err])
121
+ partition = Partition.new(
122
+ elem[:partition],
123
+ offset,
124
+ elem[:err],
125
+ elem[:metadata].null? ? nil : elem[:metadata].read_string(elem[:metadata_size])
126
+ )
122
127
  partitions.push(partition)
123
128
  data[elem[:topic]] = partitions
124
129
  end
@@ -137,33 +142,50 @@ module Rdkafka
137
142
  def to_native_tpl
138
143
  tpl = Rdkafka::Bindings.rd_kafka_topic_partition_list_new(count)
139
144
 
140
- @data.each do |topic, partitions|
141
- if partitions
142
- partitions.each do |p|
143
- Rdkafka::Bindings.rd_kafka_topic_partition_list_add(
144
- tpl,
145
- topic,
146
- p.partition
147
- )
148
-
149
- if p.offset
150
- offset = p.offset.is_a?(Time) ? p.offset.to_f * 1_000 : p.offset
151
-
152
- Rdkafka::Bindings.rd_kafka_topic_partition_list_set_offset(
145
+ begin
146
+ @data.each do |topic, partitions|
147
+ if partitions
148
+ partitions.each do |p|
149
+ ref = Rdkafka::Bindings.rd_kafka_topic_partition_list_add(
153
150
  tpl,
154
151
  topic,
155
- p.partition,
156
- offset
152
+ p.partition
157
153
  )
154
+
155
+ if p.metadata
156
+ part = Rdkafka::Bindings::TopicPartition.new(ref)
157
+ str_ptr = FFI::MemoryPointer.from_string(p.metadata)
158
+ # The metadata string is owned by librdkafka once handed over and released here:
159
+ # https://github.com/confluentinc/librdkafka/blob/e03d3bb91ed92a38f38d9806b8d8deffe78a1de5/src/rdkafka_partition.c#L2682C18-L2682C18
160
+ str_ptr.autorelease = false
161
+ part[:metadata] = str_ptr
162
+ part[:metadata_size] = p.metadata.bytesize
163
+ end
164
+
165
+ if p.offset
166
+ offset = p.offset.is_a?(Time) ? p.offset.to_f * 1_000 : p.offset
167
+
168
+ Rdkafka::Bindings.rd_kafka_topic_partition_list_set_offset(
169
+ tpl,
170
+ topic,
171
+ p.partition,
172
+ offset
173
+ )
174
+ end
158
175
  end
176
+ else
177
+ Rdkafka::Bindings.rd_kafka_topic_partition_list_add(
178
+ tpl,
179
+ topic,
180
+ Rdkafka::Bindings::RD_KAFKA_PARTITION_UA
181
+ )
159
182
  end
160
- else
161
- Rdkafka::Bindings.rd_kafka_topic_partition_list_add(
162
- tpl,
163
- topic,
164
- Rdkafka::Bindings::RD_KAFKA_PARTITION_UA
165
- )
166
183
  end
184
+ rescue Exception
185
+ # The native list is caller-owned until it is handed to librdkafka. If population raises
186
+ # partway (e.g. an invalid partition), destroy it here so it is not leaked.
187
+ Rdkafka::Bindings.rd_kafka_topic_partition_list_destroy(tpl)
188
+ raise
167
189
  end
168
190
 
169
191
  tpl
@@ -14,14 +14,45 @@ module Rdkafka
14
14
  include Enumerable
15
15
  include Helpers::Time
16
16
  include Helpers::OAuth
17
+ include Helpers::Metadata
17
18
 
18
19
  # @private
19
20
  # @param native_kafka [NativeKafka] wrapper around the native Kafka consumer handle
20
21
  def initialize(native_kafka)
21
22
  @native_kafka = native_kafka
23
+ # Single-element holder shared with the GC finalizer so it can destroy the lazily created
24
+ # consumer queue without capturing `self` (capturing the consumer in its own finalizer would
25
+ # pin it and prevent it from ever being collected).
26
+ @consumer_queue_holder = []
27
+
28
+ # Makes sure the consumer is closed (consumer queue destroyed and native client destroyed)
29
+ # before it gets GCed by Ruby.
30
+ ObjectSpace.define_finalizer(self, self.class.finalizer(native_kafka, @consumer_queue_holder))
31
+ end
32
+
33
+ # Builds the GC finalizer for a consumer. It mirrors {#close}: close the consumer, destroy the
34
+ # consumer-queue reference, then destroy the native client. The default `NativeKafka#finalizer`
35
+ # went straight to `rd_kafka_destroy`, leaving the consumer-queue reference (from
36
+ # `rd_kafka_queue_get_consumer`, taken by `poll_batch`) dangling - which can make
37
+ # `rd_kafka_destroy` block inside the finalizer (process hang at GC/shutdown) or leak the handle.
38
+ #
39
+ # @private
40
+ # @param native_kafka [NativeKafka] the wrapped native client
41
+ # @param queue_holder [Array] single-element holder carrying the consumer queue pointer (or empty)
42
+ # @return [Proc] finalizer proc that must not reference the consumer instance
43
+ def self.finalizer(native_kafka, queue_holder)
44
+ proc do
45
+ next if native_kafka.closed?
46
+
47
+ native_kafka.synchronize do |inner|
48
+ Rdkafka::Bindings.rd_kafka_consumer_close(inner)
49
+
50
+ queue = queue_holder[0]
51
+ Rdkafka::Bindings.rd_kafka_queue_destroy(queue) if queue
52
+ end
22
53
 
23
- # Makes sure, that native kafka gets closed before it gets GCed by Ruby
24
- ObjectSpace.define_finalizer(self, native_kafka.finalizer)
54
+ native_kafka.close
55
+ end
25
56
  end
26
57
 
27
58
  # Starts the native Kafka polling thread and kicks off the init polling
@@ -187,6 +218,7 @@ module Rdkafka
187
218
  if @consumer_queue
188
219
  Rdkafka::Bindings.rd_kafka_queue_destroy(@consumer_queue)
189
220
  @consumer_queue = nil
221
+ @consumer_queue_holder[0] = nil
190
222
  end
191
223
  end
192
224
 
@@ -422,6 +454,8 @@ module Rdkafka
422
454
  #
423
455
  # @raise [RdkafkaError] When getting the positions fails.
424
456
  def position(list = nil)
457
+ closed_consumer_check(__method__)
458
+
425
459
  if list.nil?
426
460
  list = assignment
427
461
  elsif !list.is_a?(TopicPartitionList)
@@ -490,8 +524,7 @@ module Rdkafka
490
524
  out = {}
491
525
 
492
526
  topic_partition_list.to_h.each do |topic, partitions|
493
- # Query high watermarks for this topic's partitions
494
- # and compare to the offset in the list.
527
+ # Query high watermarks for this topic's partitions and compare to the offset in the list.
495
528
  topic_out = {}
496
529
  partitions.each do |p|
497
530
  next if p.offset.nil?
@@ -509,11 +542,12 @@ module Rdkafka
509
542
 
510
543
  # Returns the ClusterId as reported in broker metadata.
511
544
  #
545
+ # @param timeout_ms [Integer] timeout in milliseconds to wait for the cluster id
512
546
  # @return [String, nil]
513
- def cluster_id
547
+ def cluster_id(timeout_ms = Defaults::CONSUMER_CLUSTER_ID_TIMEOUT_MS)
514
548
  closed_consumer_check(__method__)
515
549
  @native_kafka.with_inner do |inner|
516
- Rdkafka::Bindings.rd_kafka_clusterid(inner)
550
+ read_and_free_native_string(inner, Rdkafka::Bindings.rd_kafka_clusterid(inner, timeout_ms))
517
551
  end
518
552
  end
519
553
 
@@ -525,7 +559,7 @@ module Rdkafka
525
559
  def member_id
526
560
  closed_consumer_check(__method__)
527
561
  @native_kafka.with_inner do |inner|
528
- Rdkafka::Bindings.rd_kafka_memberid(inner)
562
+ read_and_free_native_string(inner, Rdkafka::Bindings.rd_kafka_memberid(inner))
529
563
  end
530
564
  end
531
565
 
@@ -534,16 +568,34 @@ module Rdkafka
534
568
  # When using this `enable.auto.offset.store` should be set to `false` in the config.
535
569
  #
536
570
  # @param message [Rdkafka::Consumer::Message] The message which offset will be stored
571
+ # @param metadata [String, nil] commit metadata string to store alongside the offset
537
572
  # @return [nil]
538
573
  # @raise [RdkafkaError] When storing the offset fails
539
- def store_offset(message)
574
+ def store_offset(message, metadata = nil)
540
575
  closed_consumer_check(__method__)
541
576
 
542
577
  list = TopicPartitionList.new
543
- list.add_topic_and_partitions_with_offsets(
544
- message.topic,
545
- message.partition => message.offset + 1
546
- )
578
+
579
+ # For metadata aware commits we build the partition reference directly to save on
580
+ # objects allocations
581
+ if metadata
582
+ list.add_topic_and_partitions_with_offsets(
583
+ message.topic,
584
+ [
585
+ Consumer::Partition.new(
586
+ message.partition,
587
+ message.offset + 1,
588
+ 0,
589
+ metadata
590
+ )
591
+ ]
592
+ )
593
+ else
594
+ list.add_topic_and_partitions_with_offsets(
595
+ message.topic,
596
+ message.partition => message.offset + 1
597
+ )
598
+ end
547
599
 
548
600
  tpl = list.to_native_tpl
549
601
 
@@ -572,8 +624,7 @@ module Rdkafka
572
624
  end
573
625
 
574
626
  # Seek to a particular message by providing the topic, partition and offset.
575
- # The next poll on the topic/partition will return the
576
- # message at the given offset.
627
+ # The next poll on the topic/partition will return the message at the given offset.
577
628
  #
578
629
  # @param topic [String] The topic in which to seek
579
630
  # @param partition [Integer] The partition number to seek
@@ -641,8 +692,7 @@ module Rdkafka
641
692
 
642
693
  # Manually commit the current offsets of this consumer.
643
694
  #
644
- # To use this set `enable.auto.commit`to `false` to disable automatic triggering
645
- # of commits.
695
+ # To use this set `enable.auto.commit`to `false` to disable automatic triggering of commits.
646
696
  #
647
697
  # If `enable.auto.offset.store` is set to `true` the offset of the last consumed
648
698
  # message for every partition is used. If set to `false` you can use {store_offset} to
@@ -841,8 +891,17 @@ module Rdkafka
841
891
  next
842
892
  end
843
893
 
844
- results << Rdkafka::Consumer::Message.new(native_message)
845
- Rdkafka::Bindings.rd_kafka_message_destroy(ptr)
894
+ begin
895
+ results << Rdkafka::Consumer::Message.new(native_message)
896
+ rescue Rdkafka::RdkafkaError => e
897
+ # A message that fails to build (e.g. a header read error) is surfaced inline as an
898
+ # error event rather than discarding the whole batch - including the messages already
899
+ # built - and raising, which silently lost them once their offsets had been stored.
900
+ results << e
901
+ ensure
902
+ Rdkafka::Bindings.rd_kafka_message_destroy(ptr)
903
+ end
904
+
846
905
  i += 1
847
906
  end
848
907
  ensure
@@ -907,8 +966,17 @@ module Rdkafka
907
966
  next
908
967
  end
909
968
 
910
- results << Rdkafka::Consumer::Message.new(native_message)
911
- Rdkafka::Bindings.rd_kafka_message_destroy(ptr)
969
+ begin
970
+ results << Rdkafka::Consumer::Message.new(native_message)
971
+ rescue Rdkafka::RdkafkaError => e
972
+ # A message that fails to build (e.g. a header read error) is surfaced inline as an
973
+ # error event rather than discarding the whole batch - including the messages already
974
+ # built - and raising, which silently lost them once their offsets had been stored.
975
+ results << e
976
+ ensure
977
+ Rdkafka::Bindings.rd_kafka_message_destroy(ptr)
978
+ end
979
+
912
980
  i += 1
913
981
  end
914
982
  ensure
@@ -994,12 +1062,34 @@ module Rdkafka
994
1062
  def closed_consumer_check(method)
995
1063
  raise Rdkafka::ClosedConsumerError.new(method) if closed?
996
1064
  end
1065
+ alias_method :closed_check, :closed_consumer_check
1066
+
1067
+ # Reads a librdkafka-allocated string and frees the underlying native buffer.
1068
+ #
1069
+ # librdkafka returns heap-allocated, caller-owned strings from functions like
1070
+ # `rd_kafka_clusterid`/`rd_kafka_memberid`. The buffer must be released with
1071
+ # `rd_kafka_mem_free`, otherwise it leaks on every call.
1072
+ #
1073
+ # @param inner [FFI::Pointer] the native client handle used to allocate the string
1074
+ # @param ptr [FFI::Pointer] the native string pointer (may be null)
1075
+ # @return [String, nil] the copied Ruby string, or nil when the pointer is null
1076
+ def read_and_free_native_string(inner, ptr)
1077
+ return nil if ptr.null?
1078
+
1079
+ ptr.read_string
1080
+ ensure
1081
+ Rdkafka::Bindings.rd_kafka_mem_free(inner, ptr) unless ptr.null?
1082
+ end
997
1083
 
998
1084
  # Returns the consumer queue pointer, lazily initialized
999
1085
  # @return [FFI::Pointer] consumer queue handle
1000
1086
  def consumer_queue
1001
1087
  @consumer_queue ||= @native_kafka.with_inner do |inner|
1002
- Rdkafka::Bindings.rd_kafka_queue_get_consumer(inner)
1088
+ queue = Rdkafka::Bindings.rd_kafka_queue_get_consumer(inner)
1089
+ # Share the pointer with the finalizer so it is destroyed even if the consumer is GC'd
1090
+ # without an explicit close.
1091
+ @consumer_queue_holder[0] = queue
1092
+ queue
1003
1093
  end
1004
1094
  end
1005
1095
 
@@ -23,6 +23,10 @@ module Rdkafka
23
23
  # @see Consumer#committed
24
24
  CONSUMER_COMMITTED_TIMEOUT_MS = 2_000
25
25
 
26
+ # Default timeout for fetching the cluster id
27
+ # @see Consumer#cluster_id
28
+ CONSUMER_CLUSTER_ID_TIMEOUT_MS = 1_000
29
+
26
30
  # Default timeout for querying watermark offsets
27
31
  # @see Consumer#query_watermark_offsets
28
32
  CONSUMER_QUERY_WATERMARK_TIMEOUT_MS = 1_000
@@ -91,6 +95,16 @@ module Rdkafka
91
95
  # @see Metadata#initialize
92
96
  METADATA_RETRY_BACKOFF_BASE_MS = 100
93
97
 
98
+ # Maximum backoff time between metadata retries. Caps the exponential backoff so a long retry
99
+ # sequence against an unhealthy cluster cannot block the calling thread for minutes.
100
+ # @see Metadata#initialize
101
+ METADATA_RETRY_BACKOFF_MAX_MS = 1_000
102
+
103
+ # Soft wall-clock budget for the whole metadata retry loop; past it (and past
104
+ # METADATA_MIN_ATTEMPTS) the loop stops so a synchronous fetch cannot block the caller for long
105
+ # @see Metadata#initialize
106
+ METADATA_RETRY_BUDGET_MS = 5_000
107
+
94
108
  # Cache settings (in milliseconds)
95
109
 
96
110
  # Default time-to-live for cached partition counts
@@ -102,5 +116,10 @@ module Rdkafka
102
116
  # Maximum number of metadata fetch retry attempts
103
117
  # @see Metadata#initialize
104
118
  METADATA_MAX_RETRIES = 10
119
+
120
+ # Minimum metadata fetch attempts before the retry budget may end the loop, so a slow broker
121
+ # (whose requests each consume the full timeout) still gets a few tries
122
+ # @see Metadata#initialize
123
+ METADATA_MIN_ATTEMPTS = 3
105
124
  end
106
125
  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
@@ -23,12 +23,57 @@ module Rdkafka
23
23
  # @param timeout_ms [Integer] timeout in milliseconds
24
24
  # @raise [RdkafkaError] when metadata fetch fails
25
25
  def initialize(native_client, topic_name = nil, timeout_ms = Defaults::METADATA_TIMEOUT_MS)
26
- attempt ||= 0
27
- attempt += 1
28
-
29
- native_topic = if topic_name
30
- Rdkafka::Bindings.rd_kafka_topic_new(native_client, topic_name, nil)
26
+ attempt = 0
27
+ deadline = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) +
28
+ Defaults::METADATA_RETRY_BUDGET_MS / 1_000.0
29
+
30
+ begin
31
+ attempt += 1
32
+ fetch_metadata(native_client, topic_name, timeout_ms)
33
+ rescue ::Rdkafka::RdkafkaError => e
34
+ raise unless RETRIED_ERRORS.include?(e.code)
35
+ raise if attempt > Defaults::METADATA_MAX_RETRIES
36
+
37
+ # Stop once the wall-clock retry budget is spent, but only after at least
38
+ # METADATA_MIN_ATTEMPTS tries so a slow broker (whose requests each consume the full
39
+ # timeout) still gets a few tries rather than being cut off after one or two.
40
+ raise if attempt >= Defaults::METADATA_MIN_ATTEMPTS &&
41
+ ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) >= deadline
42
+
43
+ # Exponential backoff between attempts, capped so a long retry sequence cannot block for
44
+ # minutes. The request timeout (`timeout_ms`) is intentionally left unchanged: it used to be
45
+ # overwritten with the backoff value, which shrank the first retries below the configured
46
+ # timeout (near-guaranteeing another timeout) and then inflated later ones to ~100s.
47
+ backoff_ms = [
48
+ (2**attempt) * Defaults::METADATA_RETRY_BACKOFF_BASE_MS,
49
+ Defaults::METADATA_RETRY_BACKOFF_MAX_MS
50
+ ].min
51
+
52
+ sleep(backoff_ms / 1_000.0)
53
+
54
+ retry
31
55
  end
56
+ end
57
+
58
+ private
59
+
60
+ # Performs a single metadata fetch attempt, freeing this attempt's native resources.
61
+ #
62
+ # Kept separate from {#initialize} so each retried attempt frees its own `native_topic` and
63
+ # metadata struct. `retry` re-enters `initialize`'s `begin` without running an `ensure` placed
64
+ # there, so doing the cleanup per attempt here is what prevents the per-retry native leak. The
65
+ # metadata struct is only allocated on success, so it is read and destroyed only after the
66
+ # result has been validated (avoids destroying a NULL/garbage pointer on a failed fetch).
67
+ #
68
+ # @param native_client [FFI::Pointer] pointer to the native Kafka client
69
+ # @param topic_name [String, nil] specific topic to fetch metadata for, or nil for all topics
70
+ # @param timeout_ms [Integer] timeout in milliseconds
71
+ # @raise [RdkafkaError] when the metadata fetch fails
72
+ def fetch_metadata(native_client, topic_name, timeout_ms)
73
+ native_topic = nil
74
+ metadata_ptr = nil
75
+
76
+ native_topic = Rdkafka::Bindings.rd_kafka_topic_new(native_client, topic_name, nil) if topic_name
32
77
 
33
78
  ptr = FFI::MemoryPointer.new(:pointer)
34
79
 
@@ -42,24 +87,16 @@ module Rdkafka
42
87
  # Error Handling
43
88
  raise Rdkafka::RdkafkaError.new(result) unless result.zero?
44
89
 
45
- metadata_from_native(ptr.read_pointer)
46
- rescue ::Rdkafka::RdkafkaError => e
47
- raise unless RETRIED_ERRORS.include?(e.code)
48
- raise if attempt > Defaults::METADATA_MAX_RETRIES
90
+ # rd_kafka_metadata only allocates the struct on success, so we read the pointer to destroy
91
+ # only after the result has been confirmed successful.
92
+ metadata_ptr = ptr.read_pointer
49
93
 
50
- backoff_factor = 2**attempt
51
- timeout_ms = backoff_factor * Defaults::METADATA_RETRY_BACKOFF_BASE_MS
52
-
53
- sleep(timeout_ms / 1000.0)
54
-
55
- retry
94
+ metadata_from_native(metadata_ptr)
56
95
  ensure
57
- Rdkafka::Bindings.rd_kafka_topic_destroy(native_topic) if topic_name
58
- Rdkafka::Bindings.rd_kafka_metadata_destroy(ptr.read_pointer)
96
+ Rdkafka::Bindings.rd_kafka_topic_destroy(native_topic) if native_topic
97
+ Rdkafka::Bindings.rd_kafka_metadata_destroy(metadata_ptr) if metadata_ptr && !metadata_ptr.null?
59
98
  end
60
99
 
61
- private
62
-
63
100
  # Extracts metadata from native pointer
64
101
  # @param ptr [FFI::Pointer] pointer to native metadata
65
102
  def metadata_from_native(ptr)
@@ -134,6 +171,36 @@ module Rdkafka
134
171
  :replicas, :pointer,
135
172
  :in_sync_replica_brokers, :int,
136
173
  :isrs, :pointer
174
+
175
+ # The base `#to_h` skips FFI pointer members, which would drop the replica and in-sync
176
+ # replica assignments entirely. We dereference those pointers here so the partition hash
177
+ # exposes the broker ids backing the partition (needed e.g. to plan replication changes).
178
+ #
179
+ # @return [Hash{Symbol => Integer, Array<Integer>}] partition metadata:
180
+ # * +:partition_id+ (Integer) - partition id
181
+ # * +:leader+ (Integer) - broker id of the partition leader
182
+ # * +:replica_count+ (Integer) - number of assigned replicas
183
+ # * +:in_sync_replica_brokers+ (Integer) - number of in-sync replicas
184
+ # * +:replicas+ (Array<Integer>) - broker ids of the assigned replicas
185
+ # * +:isrs+ (Array<Integer>) - broker ids of the in-sync replicas
186
+ def to_h
187
+ super.merge(
188
+ replicas: read_broker_ids(self[:replicas], self[:replica_count]),
189
+ isrs: read_broker_ids(self[:isrs], self[:in_sync_replica_brokers])
190
+ )
191
+ end
192
+
193
+ private
194
+
195
+ # Reads `count` broker ids (int32) from a replicas/isrs pointer.
196
+ # @param pointer [FFI::Pointer] pointer to the broker ids array
197
+ # @param count [Integer] number of broker ids to read
198
+ # @return [Array<Integer>] broker ids (empty when there are none)
199
+ def read_broker_ids(pointer, count)
200
+ return [] if count.zero? || pointer.null?
201
+
202
+ pointer.read_array_of_int32(count)
203
+ end
137
204
  end
138
205
  end
139
206
  end
@@ -55,8 +55,7 @@ module Rdkafka
55
55
  Rdkafka::Bindings.rd_kafka_poll(@inner, 0)
56
56
 
57
57
  if @run_polling_thread
58
- # Start thread to poll client for delivery callbacks,
59
- # not used in consumer.
58
+ # Start thread to poll client for delivery callbacks, not used in consumer.
60
59
  @polling_thread = Thread.new do
61
60
  loop do
62
61
  @poll_mutex.synchronize do
@@ -197,8 +196,7 @@ module Rdkafka
197
196
  # Indicate to polling thread that we're closing
198
197
  @polling_thread[:closing] = true
199
198
 
200
- # Wait for the polling thread to finish up,
201
- # this can be aborted in practice if this
199
+ # Wait for the polling thread to finish up, this can be aborted in practice if this
202
200
  # code runs from a finalizer.
203
201
  @polling_thread.join
204
202
  end
@@ -8,14 +8,14 @@ module Rdkafka
8
8
  layout :pending, :bool,
9
9
  :response, :int,
10
10
  :partition, :int,
11
- :offset, :int64,
12
- :topic_name, :pointer
11
+ :offset, :int64
13
12
 
14
13
  # @return [Object, nil] label set during message production or nil by default
15
14
  attr_accessor :label
16
15
 
17
16
  # @return [String] topic where we are trying to send the message
18
- # We use this instead of reading from `topic_name` pointer to save on memory allocations
17
+ # Set in `#produce`, where the topic is known upfront. Keeping it as a Ruby attribute
18
+ # spares a per-message native string copy in the delivery callback.
19
19
  attr_accessor :topic
20
20
 
21
21
  # @return [String] the name of the operation (e.g. "delivery")
@@ -37,12 +37,11 @@ module Rdkafka
37
37
  # contention in multi-threaded environments while ensuring data consistency.
38
38
  #
39
39
  # 6. Topic recreation handling
40
- # If a topic is deleted and recreated with fewer partitions, the cache will continue to
41
- # report the higher count until either the TTL expires or the process is restarted. This
42
- # design choice simplifies the implementation while relying on librdkafka's error handling
43
- # for edge cases. In production environments, topic recreation with different partition
44
- # counts is typically accompanied by application restarts to handle structural changes.
45
- # This also aligns with the previous cache implementation.
40
+ # If a topic is deleted and recreated with fewer partitions, the cache keeps reporting the
41
+ # higher count only until the entry's TTL expires. The first refresh after expiry performs
42
+ # an authoritative metadata read and adopts the lower count. Within the TTL window a lower
43
+ # value is still ignored, so a transient or racy lower read cannot clobber a correct higher
44
+ # count.
46
45
  class PartitionsCountCache
47
46
  include Helpers::Time
48
47
 
@@ -91,28 +90,14 @@ module Rdkafka
91
90
  current_info = @counts[topic]
92
91
 
93
92
  if current_info.nil? || expired?(current_info[0])
93
+ # The cached entry is missing or expired, so the block performs an authoritative metadata
94
+ # read. We hand it to `set`, which adopts a higher count always and a lower count once the
95
+ # entry has expired (e.g. the topic was recreated with fewer partitions). We then return
96
+ # whatever `set` settled on so a concurrent refresh that wrote a higher value still wins.
94
97
  new_count = yield
98
+ set(topic, new_count)
95
99
 
96
- if current_info.nil?
97
- # No existing data, create a new entry with mutex
98
- set(topic, new_count)
99
-
100
- return new_count
101
- else
102
- current_count = current_info[1]
103
-
104
- if new_count > current_count
105
- # Higher value needs mutex to update both timestamp and count
106
- set(topic, new_count)
107
-
108
- return new_count
109
- else
110
- # Same or lower value, just update timestamp without mutex
111
- refresh_timestamp(topic)
112
-
113
- return current_count
114
- end
115
- end
100
+ return @counts[topic][1]
116
101
  end
117
102
 
118
103
  current_info[1]
@@ -133,8 +118,11 @@ module Rdkafka
133
118
  # First check outside mutex to avoid unnecessary locking
134
119
  current_info = @counts[topic]
135
120
 
136
- # For lower values, we don't update count but might need to refresh timestamp
137
- if current_info && new_count < current_info[1]
121
+ # Within the TTL window a lower value is treated as a stale/racy read and ignored, since
122
+ # partition counts only grow during normal operation. Once the entry has expired a lower
123
+ # value is an authoritative refresh (e.g. the topic was recreated with fewer partitions),
124
+ # so we fall through and adopt it below.
125
+ if current_info && new_count < current_info[1] && !expired?(current_info[0])
138
126
  refresh_timestamp(topic)
139
127
 
140
128
  return
@@ -148,17 +136,15 @@ module Rdkafka
148
136
  if current_info.nil?
149
137
  # Create new entry
150
138
  @counts[topic] = [monotonic_now_ms, new_count]
139
+ elsif new_count > current_info[1] || expired?(current_info[0])
140
+ # A higher count always wins; a lower count is accepted only when the existing entry
141
+ # has expired, so a concurrent fresh higher value (which reset the timestamp) is never
142
+ # clobbered by a stale lower one.
143
+ current_info[0] = monotonic_now_ms
144
+ current_info[1] = new_count
151
145
  else
152
- current_count = current_info[1]
153
-
154
- if new_count > current_count
155
- # Update to higher count value
156
- current_info[0] = monotonic_now_ms
157
- current_info[1] = new_count
158
- else
159
- # Same or lower count, update timestamp only
160
- current_info[0] = monotonic_now_ms
161
- end
146
+ # Same or lower count within the TTL window: refresh the timestamp only
147
+ current_info[0] = monotonic_now_ms
162
148
  end
163
149
  end
164
150
  end